diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index d9723d2..06f674a 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -29,6 +29,14 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
+ externalNativeBuild {
+ cmake {
+ arguments += listOf("-DANDROID_STL=c++_shared")
+ }
+ }
+ ndk {
+ abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
+ }
}
buildTypes {
@@ -38,6 +46,12 @@ android {
signingConfig = signingConfigs.getByName("debug")
}
}
+
+ externalNativeBuild {
+ cmake {
+ path = file("src/main/cpp/CMakeLists.txt")
+ }
+ }
}
flutter {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 1f6da2d..646400d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,11 +12,22 @@
+
+
+
+
+
+
+
:debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('Flutter', 'Generated.xcconfig'), __dir__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first."
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise 'FLUTTER_ROOT not found in Generated.xcconfig. Try deleting Flutter/Generated.xcconfig, then run flutter pub get.'
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ pod 'codec2', :path => '../third_party/codec2'
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 2b505d0..9a2f752 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -45,9 +45,15 @@
UIApplicationSupportsIndirectInputEvents
+ UIBackgroundModes
+
+ bluetooth-central
+
NSBluetoothAlwaysUsageDescription
This app uses Bluetooth to communicate with MeshCore devices.
NSBluetoothPeripheralUsageDescription
This app uses Bluetooth to communicate with MeshCore devices.
+ NSMicrophoneUsageDescription
+ This app needs microphone access to record voice messages.
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 60880df..c22477a 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -1,11 +1,13 @@
import 'dart:async';
import 'dart:convert';
+import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:uuid/uuid.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
@@ -17,7 +19,9 @@ import '../services/ble_debug_log_service.dart';
import '../services/message_retry_service.dart';
import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
+import '../services/background_service.dart';
import '../services/notification_service.dart';
+import '../services/voice_message_service.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
@@ -48,6 +52,10 @@ class MeshCoreConnector extends ChangeNotifier {
BluetoothCharacteristic? _txCharacteristic;
String? _deviceDisplayName;
String? _deviceId;
+ BluetoothDevice? _lastDevice;
+ String? _lastDeviceId;
+ String? _lastDeviceDisplayName;
+ bool _manualDisconnect = false;
final List _scanResults = [];
final List _contacts = [];
@@ -60,6 +68,8 @@ class MeshCoreConnector extends ChangeNotifier {
StreamSubscription? _connectionSubscription;
StreamSubscription>? _notifySubscription;
Timer? _selfInfoRetryTimer;
+ Timer? _reconnectTimer;
+ int _reconnectAttempts = 0;
final StreamController _receivedFramesController =
StreamController.broadcast();
@@ -93,6 +103,7 @@ class MeshCoreConnector extends ChangeNotifier {
MessageRetryService? _retryService;
PathHistoryService? _pathHistoryService;
AppSettingsService? _appSettingsService;
+ BackgroundService? _backgroundService;
final NotificationService _notificationService = NotificationService();
BleDebugLogService? _bleDebugLogService;
final ChannelMessageStore _channelMessageStore = ChannelMessageStore();
@@ -102,6 +113,9 @@ class MeshCoreConnector extends ChangeNotifier {
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore();
final UnreadStore _unreadStore = UnreadStore();
+ final VoiceMessageService _voiceMessageService = VoiceMessageService.instance;
+ final Map _voiceAssemblies = {};
+ _VoiceSendSession? _voiceSendSession;
final Map _channelSmazEnabled = {};
final Map _contactSmazEnabled = {};
final Set _knownContactKeys = {};
@@ -110,12 +124,23 @@ class MeshCoreConnector extends ChangeNotifier {
String? _activeContactKey;
int? _activeChannelIndex;
List _channelOrder = [];
+ int _lastVoiceTimestampSeconds = 0;
// Getters
MeshCoreConnectionState get state => _state;
BluetoothDevice? get device => _device;
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
+ bool get isVoiceSending => _voiceSendSession != null;
+
+ void cancelVoiceSend() {
+ final session = _voiceSendSession;
+ if (session == null) return;
+ session.cancel();
+ _voiceSendSession = null;
+ _updateVoiceMessageStatus(session.messageId, MessageStatus.failed);
+ notifyListeners();
+ }
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
return _selfName!;
@@ -130,7 +155,15 @@ class MeshCoreConnector extends ChangeNotifier {
return 'Unknown Device';
}
List get scanResults => List.unmodifiable(_scanResults);
- List get contacts => List.unmodifiable(_contacts);
+ List get contacts {
+ final selfKey = _selfPublicKey;
+ if (selfKey == null) {
+ return List.unmodifiable(_contacts);
+ }
+ return List.unmodifiable(
+ _contacts.where((contact) => !listEquals(contact.publicKey, selfKey)),
+ );
+ }
List get channels => List.unmodifiable(_channels);
bool get isConnected => _state == MeshCoreConnectionState.connected;
bool get isLoadingContacts => _isLoadingContacts;
@@ -194,6 +227,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return;
final removed = messages.remove(message);
if (!removed) return;
+ if (message.isVoice && message.voicePath != null) {
+ final file = File(message.voicePath!);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ }
await _messageStore.saveMessages(contactKeyHex, messages);
notifyListeners();
}
@@ -358,11 +397,13 @@ class MeshCoreConnector extends ChangeNotifier {
required PathHistoryService pathHistoryService,
AppSettingsService? appSettingsService,
BleDebugLogService? bleDebugLogService,
+ BackgroundService? backgroundService,
}) {
_retryService = retryService;
_pathHistoryService = pathHistoryService;
_appSettingsService = appSettingsService;
_bleDebugLogService = bleDebugLogService;
+ _backgroundService = backgroundService;
// Initialize notification service
_notificationService.initialize();
@@ -461,6 +502,7 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
+ lastMessageAt: contact.lastMessageAt,
);
}
@@ -515,6 +557,12 @@ class MeshCoreConnector extends ChangeNotifier {
} else if (device.platformName.isNotEmpty) {
_deviceDisplayName = device.platformName;
}
+ _lastDevice = device;
+ _lastDeviceId = _deviceId;
+ _lastDeviceDisplayName = _deviceDisplayName;
+ _manualDisconnect = false;
+ _cancelReconnectTimer();
+ unawaited(_backgroundService?.start());
notifyListeners();
try {
@@ -565,6 +613,9 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
+ // Give the device a moment to be ready for descriptor writes
+ await Future.delayed(const Duration(milliseconds: 300));
+
await _txCharacteristic!.setNotifyValue(true);
_notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame);
@@ -583,7 +634,7 @@ class MeshCoreConnector extends ChangeNotifier {
await syncTime();
} catch (e) {
debugPrint("Connection error: $e");
- await disconnect();
+ await disconnect(manual: false);
rethrow;
}
}
@@ -619,9 +670,58 @@ class MeshCoreConnector extends ChangeNotifier {
return result;
}
- Future disconnect() async {
+ bool get _shouldAutoReconnect =>
+ !_manualDisconnect && _lastDeviceId != null;
+
+ void _cancelReconnectTimer() {
+ _reconnectTimer?.cancel();
+ _reconnectTimer = null;
+ _reconnectAttempts = 0;
+ }
+
+ int _nextReconnectDelayMs() {
+ final attempt = _reconnectAttempts < 6 ? _reconnectAttempts : 6;
+ _reconnectAttempts += 1;
+ final delayMs = 1000 * (1 << attempt);
+ return delayMs > 30000 ? 30000 : delayMs;
+ }
+
+ void _scheduleReconnect() {
+ if (!_shouldAutoReconnect) return;
+ if (_reconnectTimer?.isActive == true) return;
+
+ final delayMs = _nextReconnectDelayMs();
+ _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
+ if (!_shouldAutoReconnect) return;
+ if (_state == MeshCoreConnectionState.connecting ||
+ _state == MeshCoreConnectionState.connected) {
+ return;
+ }
+
+ final device = _lastDevice ??
+ (_lastDeviceId == null
+ ? null
+ : BluetoothDevice.fromId(_lastDeviceId!));
+ if (device == null) return;
+
+ try {
+ await connect(device, displayName: _lastDeviceDisplayName);
+ } catch (_) {
+ _scheduleReconnect();
+ }
+ });
+ }
+
+ Future disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return;
+ if (manual) {
+ _manualDisconnect = true;
+ _cancelReconnectTimer();
+ unawaited(_backgroundService?.stop());
+ } else {
+ _manualDisconnect = false;
+ }
_setState(MeshCoreConnectionState.disconnecting);
await _notifySubscription?.cancel();
@@ -633,7 +733,8 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
try {
- await _device?.disconnect();
+ // Skip queued BLE operations so disconnect doesn't get stuck behind them.
+ await _device?.disconnect(queue: false);
} catch (e) {
debugPrint("Disconnect error: $e");
}
@@ -663,6 +764,9 @@ class MeshCoreConnector extends ChangeNotifier {
_didInitialQueueSync = false;
_setState(MeshCoreConnectionState.disconnected);
+ if (!manual) {
+ _scheduleReconnect();
+ }
}
Future sendFrame(Uint8List data) async {
@@ -762,6 +866,10 @@ class MeshCoreConnector extends ChangeNotifier {
int? customPathLen,
}) async {
if (!isConnected || text.isEmpty) return;
+ if (_voiceSendSession != null) {
+ debugPrint('Voice send in progress, skipping text send.');
+ return;
+ }
// If custom path is provided, temporarily update the contact's path
if (customPath != null && customPathLen != null && customPathLen >= 0) {
@@ -825,6 +933,142 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ Future sendVoiceMessage({
+ required Contact contact,
+ required Uint8List codec2Bytes,
+ required String voicePath,
+ required int durationMs,
+ int? timestampSeconds,
+ }) async {
+ if (!isConnected || codec2Bytes.isEmpty) return;
+ if (_voiceSendSession != null) return;
+
+ final voiceTimestampSeconds = timestampSeconds ?? _nextVoiceTimestampSeconds();
+ final chunks = _voiceMessageService.buildVoiceChunks(codec2Bytes);
+ if (chunks.isEmpty) return;
+
+ final messageId = const Uuid().v4();
+ final message = Message(
+ senderKey: contact.publicKey,
+ text: 'Voice message',
+ timestamp: DateTime.fromMillisecondsSinceEpoch(voiceTimestampSeconds * 1000),
+ isOutgoing: true,
+ isCli: false,
+ status: MessageStatus.pending,
+ messageId: messageId,
+ forceFlood: false,
+ isVoice: true,
+ voicePath: voicePath,
+ voiceDurationMs: durationMs,
+ voiceCodec: VoiceMessageService.codecName,
+ );
+
+ _addMessage(contact.publicKeyHex, message);
+ notifyListeners();
+
+ final session = _VoiceSendSession(
+ contact: contact,
+ messageId: messageId,
+ chunks: chunks,
+ timestampSeconds: voiceTimestampSeconds,
+ );
+ _voiceSendSession = session;
+ notifyListeners();
+
+ unawaited(_sendVoiceChunks(session));
+ }
+
+ int reserveVoiceTimestampSeconds() {
+ return _nextVoiceTimestampSeconds();
+ }
+
+ int _nextVoiceTimestampSeconds() {
+ final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
+ if (nowSeconds <= _lastVoiceTimestampSeconds) {
+ _lastVoiceTimestampSeconds += 1;
+ } else {
+ _lastVoiceTimestampSeconds = nowSeconds;
+ }
+ return _lastVoiceTimestampSeconds;
+ }
+
+ Future _sendVoiceChunks(_VoiceSendSession session) async {
+ for (var i = 0; i < session.chunks.length; i++) {
+ if (session.isCancelled) return;
+ final ok = await _sendVoiceChunk(session, i);
+ if (!ok) {
+ if (session.isCancelled) return;
+ _updateVoiceMessageStatus(session.messageId, MessageStatus.failed);
+ _voiceSendSession = null;
+ notifyListeners();
+ return;
+ }
+ }
+ if (session.isCancelled) return;
+ _updateVoiceMessageStatus(session.messageId, MessageStatus.delivered);
+ _voiceSendSession = null;
+ notifyListeners();
+ }
+
+ Future _sendVoiceChunk(_VoiceSendSession session, int index) async {
+ if (session.isCancelled) return false;
+ session.beginChunk(index);
+ await sendFrame(
+ buildSendTextMsgFrame(
+ session.contact.publicKey,
+ session.chunks[index],
+ forceFlood: false,
+ attempt: 0,
+ timestampSeconds: session.timestampSeconds,
+ ),
+ );
+
+ try {
+ await session.sentCompleter!.future.timeout(const Duration(seconds: 10));
+ } catch (_) {
+ return false;
+ }
+
+ final timeoutMs = session.expectedTimeoutMs;
+ final confirmTimeout = timeoutMs != null && timeoutMs > 0
+ ? Duration(milliseconds: timeoutMs)
+ : const Duration(seconds: 30);
+
+ try {
+ await session.confirmCompleter!.future.timeout(confirmTimeout);
+ } catch (_) {
+ return false;
+ }
+ return true;
+ }
+
+ void _updateVoiceMessageStatus(String messageId, MessageStatus status) {
+ for (final entry in _conversations.entries) {
+ final messages = entry.value;
+ final index = messages.indexWhere((m) => m.messageId == messageId);
+ if (index == -1) continue;
+ messages[index] = messages[index].copyWith(status: status);
+ _messageStore.saveMessages(entry.key, messages);
+ break;
+ }
+ }
+
+ void _handleVoiceMessageSent(Uint8List ackHash, int timeoutMs, {required bool isFlood}) {
+ final session = _voiceSendSession;
+ if (session == null) return;
+ session.handleSent(ackHash, timeoutMs);
+ if (isFlood) {
+ // Flooded sends may not emit send-confirmed; unblock voice chunking.
+ session.handleConfirmed(ackHash);
+ }
+ }
+
+ void _handleVoiceSendConfirmed(Uint8List ackHash) {
+ final session = _voiceSendSession;
+ if (session == null) return;
+ session.handleConfirmed(ackHash);
+ }
+
Future setContactPath(Contact contact, Uint8List customPath, int pathLen) async {
if (!isConnected) return;
@@ -839,6 +1083,10 @@ class MeshCoreConnector extends ChangeNotifier {
Future sendChannelMessage(Channel channel, String text) async {
if (!isConnected || text.isEmpty) return;
+ if (_voiceSendSession != null) {
+ debugPrint('Voice send in progress, skipping channel send.');
+ return;
+ }
final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
_addChannelMessage(channel.index, message);
@@ -886,6 +1134,7 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: existing.latitude,
longitude: existing.longitude,
lastSeen: existing.lastSeen,
+ lastMessageAt: existing.lastMessageAt,
);
notifyListeners();
unawaited(_persistContacts());
@@ -1233,7 +1482,13 @@ class MeshCoreConnector extends ChangeNotifier {
);
if (existingIndex >= 0) {
- _contacts[existingIndex] = contact;
+ final existing = _contacts[existingIndex];
+ final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
+ ? existing.lastMessageAt
+ : contact.lastMessageAt;
+ _contacts[existingIndex] = contact.copyWith(
+ lastMessageAt: mergedLastMessageAt,
+ );
} else {
_contacts.add(contact);
}
@@ -1275,6 +1530,83 @@ class MeshCoreConnector extends ChangeNotifier {
return latest;
}
+ bool _setContactLastMessageAt(int index, DateTime timestamp) {
+ final contact = _contacts[index];
+ if (contact.type != advTypeChat) return false;
+ if (!timestamp.isAfter(contact.lastMessageAt)) return false;
+ _contacts[index] = contact.copyWith(lastMessageAt: timestamp);
+ return true;
+ }
+
+ void _updateContactLastMessageAt(
+ String contactKeyHex,
+ DateTime timestamp, {
+ bool notify = false,
+ }) {
+ final index = _contacts.indexWhere((c) => c.publicKeyHex == contactKeyHex);
+ if (index < 0) return;
+ if (!_setContactLastMessageAt(index, timestamp)) return;
+ unawaited(_persistContacts());
+ if (notify) {
+ notifyListeners();
+ }
+ }
+
+ void _updateContactLastMessageAtByName(
+ String senderName,
+ DateTime timestamp, {
+ Uint8List? pathBytes,
+ bool notify = false,
+ }) {
+ final normalized = senderName.trim().toLowerCase();
+ final hasName = normalized.isNotEmpty && normalized != 'unknown';
+ var updated = false;
+ var matchedByName = false;
+
+ if (hasName) {
+ for (var i = 0; i < _contacts.length; i++) {
+ final contact = _contacts[i];
+ if (contact.type != advTypeChat) continue;
+ if (contact.name.trim().toLowerCase() == normalized) {
+ matchedByName = true;
+ updated = _setContactLastMessageAt(i, timestamp) || updated;
+ }
+ }
+ }
+
+ if (!matchedByName && pathBytes != null && pathBytes.isNotEmpty) {
+ final matches = [];
+ for (var i = 0; i < _contacts.length; i++) {
+ final contact = _contacts[i];
+ if (contact.type != advTypeChat) continue;
+ if (_pathMatchesContact(pathBytes, contact.publicKey)) {
+ matches.add(i);
+ }
+ }
+ if (matches.length == 1) {
+ updated = _setContactLastMessageAt(matches.first, timestamp) || updated;
+ }
+ }
+
+ if (updated) {
+ unawaited(_persistContacts());
+ if (notify) {
+ notifyListeners();
+ }
+ }
+ }
+
+ bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) {
+ if (pathBytes.isEmpty || publicKey.length < pathHashSize) return false;
+ for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) {
+ final prefix = pathBytes.sublist(i, i + pathHashSize);
+ if (_matchesPrefix(publicKey, prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
void _handleIncomingMessage(Uint8List frame) {
if (_selfPublicKey == null) return;
@@ -1290,6 +1622,12 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: contact.pathLength < 0 ? Uint8List(0) : contact.path,
);
}
+ if (_tryHandleVoiceChunk(message)) {
+ return;
+ }
+ if (contact != null) {
+ _updateContactLastMessageAt(contact.publicKeyHex, message.timestamp);
+ }
if (!message.isOutgoing) {
final existing = _conversations[message.senderKeyHex];
final incomingTimestamp = message.timestamp.millisecondsSinceEpoch;
@@ -1404,22 +1742,179 @@ class MeshCoreConnector extends ChangeNotifier {
String _prepareContactOutboundText(Contact contact, String text) {
final trimmed = text.trim();
- final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:');
+ final isStructuredPayload =
+ trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
if (!isStructuredPayload && isContactSmazEnabled(contact.publicKeyHex)) {
return Smaz.encodeIfSmaller(text);
}
return text;
}
+ bool _tryHandleVoiceChunk(Message message) {
+ if (message.isOutgoing || message.isCli) return false;
+ final chunk = _voiceMessageService.tryParseChunk(message.text);
+ if (chunk == null) return false;
+ _updateContactLastMessageAt(
+ message.senderKeyHex,
+ message.timestamp,
+ notify: true,
+ );
+ final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
+ final key = _voiceAssemblyKey(message.senderKeyHex, timestampSeconds);
+ final assembly = _voiceAssemblies.putIfAbsent(
+ key,
+ () => _VoiceAssembly(
+ senderKey: message.senderKey,
+ senderKeyHex: message.senderKeyHex,
+ timestampSeconds: timestampSeconds,
+ totalChunks: chunk.count,
+ ),
+ );
+ if (assembly.totalChunks != chunk.count) {
+ _voiceAssemblies.remove(key);
+ return true;
+ }
+ assembly.addChunk(chunk);
+ if (assembly.isComplete) {
+ _voiceAssemblies.remove(key);
+ unawaited(_finalizeVoiceAssembly(assembly, message));
+ }
+ _cleanupVoiceAssemblies();
+ if (_isSyncingQueuedMessages) {
+ _handleQueuedMessageReceived();
+ }
+ return true;
+ }
+
+ String _voiceAssemblyKey(String senderKeyHex, int timestampSeconds) {
+ return '$senderKeyHex:$timestampSeconds';
+ }
+
+ Future _finalizeVoiceAssembly(_VoiceAssembly assembly, Message chunkMessage) async {
+ final codec2Bytes = assembly.assemble();
+ if (codec2Bytes.isEmpty) return;
+ final existing = _conversations[assembly.senderKeyHex];
+ if (existing != null) {
+ final alreadyAdded = existing.any((message) {
+ if (!message.isVoice) return false;
+ final tsSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
+ return tsSeconds == assembly.timestampSeconds;
+ });
+ if (alreadyAdded) return;
+ }
+ String? filePath;
+ int durationMs = 0;
+ try {
+ final pcmBytes = _voiceMessageService.decodeCodec2ToPcm(codec2Bytes);
+ durationMs = _voiceMessageService.durationMsForCodec2Bytes(codec2Bytes);
+ final fileName = _voiceMessageService.buildVoiceFileName(
+ senderKeyHex: assembly.senderKeyHex,
+ timestampSeconds: assembly.timestampSeconds,
+ );
+ filePath = await _voiceMessageService.writeWavFile(
+ pcmBytes: pcmBytes,
+ fileName: fileName,
+ );
+ } catch (e) {
+ debugPrint('Voice decode failed: $e');
+ return;
+ }
+
+ final message = Message(
+ senderKey: assembly.senderKey,
+ text: 'Voice message',
+ timestamp: DateTime.fromMillisecondsSinceEpoch(assembly.timestampSeconds * 1000),
+ isOutgoing: false,
+ isCli: false,
+ status: MessageStatus.delivered,
+ isVoice: true,
+ voicePath: filePath,
+ voiceDurationMs: durationMs,
+ voiceCodec: VoiceMessageService.codecName,
+ pathLength: chunkMessage.pathLength,
+ pathBytes: chunkMessage.pathBytes,
+ );
+
+ _addMessage(assembly.senderKeyHex, message);
+ _maybeMarkActiveContactRead(message);
+ notifyListeners();
+
+ if (_appSettingsService != null) {
+ final settings = _appSettingsService!.settings;
+ if (settings.notificationsEnabled && settings.notifyOnNewMessage) {
+ final contact = _contacts.cast().firstWhere(
+ (c) => c != null && c.publicKeyHex == assembly.senderKeyHex,
+ orElse: () => null,
+ );
+ _notificationService.showMessageNotification(
+ contactName: contact?.name ?? 'Unknown',
+ message: 'Voice message',
+ contactId: assembly.senderKeyHex,
+ );
+ }
+ }
+ }
+
+ void _cleanupVoiceAssemblies() {
+ if (_voiceAssemblies.isEmpty) return;
+ final cutoff = DateTime.now().subtract(const Duration(minutes: 3));
+ final expiredKeys = [];
+ for (final entry in _voiceAssemblies.entries) {
+ if (entry.value.startedAt.isBefore(cutoff)) {
+ expiredKeys.add(entry.key);
+ }
+ }
+ for (final key in expiredKeys) {
+ _voiceAssemblies.remove(key);
+ }
+ }
+
+ String _channelDisplayName(int channelIndex) {
+ for (final channel in _channels) {
+ if (channel.index != channelIndex) continue;
+ return channel.name.isEmpty ? 'Channel $channelIndex' : channel.name;
+ }
+ return 'Channel $channelIndex';
+ }
+
+ void _maybeNotifyChannelMessage(
+ ChannelMessage message, {
+ String? channelName,
+ }) {
+ if (message.isOutgoing || _appSettingsService == null) return;
+ final channelIndex = message.channelIndex;
+ if (channelIndex == null) return;
+
+ final settings = _appSettingsService!.settings;
+ if (!settings.notificationsEnabled || !settings.notifyOnNewChannelMessage) {
+ return;
+ }
+
+ final label = channelName ?? _channelDisplayName(channelIndex);
+ _notificationService.showChannelMessageNotification(
+ channelName: label,
+ message: message.text,
+ channelIndex: channelIndex,
+ );
+ }
+
void _handleIncomingChannelMessage(Uint8List frame) {
final message = ChannelMessage.fromFrame(frame);
if (message != null && message.channelIndex != null) {
if (_shouldDropSelfChannelMessage(message.senderName, message.pathBytes)) {
return;
}
- _addChannelMessage(message.channelIndex!, message);
+ _updateContactLastMessageAtByName(
+ message.senderName,
+ message.timestamp,
+ pathBytes: message.pathBytes,
+ );
+ final isNew = _addChannelMessage(message.channelIndex!, message);
_maybeMarkActiveChannelRead(message);
notifyListeners();
+ if (isNew) {
+ _maybeNotifyChannelMessage(message);
+ }
_handleQueuedMessageReceived();
} else if (_isSyncingQueuedMessages) {
_handleQueuedMessageReceived();
@@ -1470,9 +1965,18 @@ class MeshCoreConnector extends ChangeNotifier {
channelIndex: channel.index,
);
- _addChannelMessage(channel.index, message);
+ _updateContactLastMessageAtByName(
+ parsed.senderName,
+ message.timestamp,
+ pathBytes: message.pathBytes,
+ );
+ final isNew = _addChannelMessage(channel.index, message);
_maybeMarkActiveChannelRead(message);
notifyListeners();
+ if (isNew) {
+ final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name;
+ _maybeNotifyChannelMessage(message, channelName: label);
+ }
return;
}
}
@@ -1484,11 +1988,15 @@ class MeshCoreConnector extends ChangeNotifier {
// [2-5] = expected_ack_hash (uint32)
// [6-9] = estimated_timeout_ms (uint32)
- if (frame.length >= 10 && _retryService != null) {
+ if (frame.length >= 10) {
+ final isFlood = frame[1] != 0;
final ackHash = Uint8List.fromList(frame.sublist(2, 6));
final timeoutMs = readUint32LE(frame, 6);
- _retryService!.updateMessageFromSent(ackHash, timeoutMs);
+ if (_retryService != null) {
+ _retryService!.updateMessageFromSent(ackHash, timeoutMs);
+ }
+ _handleVoiceMessageSent(ackHash, timeoutMs, isFlood: isFlood);
} else {
// Fallback to old behavior
for (var messages in _conversations.values) {
@@ -1517,6 +2025,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs);
}
+ _handleVoiceSendConfirmed(ackHash);
} else {
// Fallback to old behavior
for (var messages in _conversations.values) {
@@ -1564,8 +2073,8 @@ class MeshCoreConnector extends ChangeNotifier {
Future setChannelOrder(List order) async {
_channelOrder = List.from(order);
_applyChannelOrder();
- await _channelOrderStore.saveChannelOrder(_channelOrder);
notifyListeners();
+ await _channelOrderStore.saveChannelOrder(_channelOrder);
}
bool _shouldTrackUnreadForContactKey(String contactKeyHex) {
@@ -1760,17 +2269,26 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.pathLength;
}
- void _addChannelMessage(int channelIndex, ChannelMessage message) {
+ bool _addChannelMessage(int channelIndex, ChannelMessage message) {
_channelMessages.putIfAbsent(channelIndex, () => []);
final messages = _channelMessages[channelIndex]!;
final existingIndex = _findChannelRepeatIndex(messages, message);
+ var isNew = true;
if (existingIndex >= 0) {
+ isNew = false;
final existing = messages[existingIndex];
- final mergedPathBytes = existing.pathBytes.isEmpty ? message.pathBytes : existing.pathBytes;
+ final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, message.pathBytes);
+ final mergedPathVariants = _mergePathVariants(existing.pathVariants, message.pathVariants);
+ final mergedPathLength = _mergePathLength(
+ existing.pathLength,
+ message.pathLength,
+ mergedPathBytes.length,
+ );
messages[existingIndex] = existing.copyWith(
repeatCount: existing.repeatCount + 1,
- pathLength: message.pathLength ?? existing.pathLength,
+ pathLength: mergedPathLength,
pathBytes: mergedPathBytes,
+ pathVariants: mergedPathVariants,
);
} else {
messages.add(message);
@@ -1781,6 +2299,7 @@ class MeshCoreConnector extends ChangeNotifier {
channelIndex,
messages,
);
+ return isNew;
}
int _findChannelRepeatIndex(List messages, ChannelMessage incoming) {
@@ -1838,6 +2357,56 @@ class MeshCoreConnector extends ChangeNotifier {
return false;
}
+ Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) {
+ if (incoming.isEmpty) return existing;
+ if (existing.isEmpty) return incoming;
+ if (incoming.length > existing.length) return incoming;
+ return existing;
+ }
+
+ int? _mergePathLength(int? existing, int? incoming, int observedLength) {
+ if (existing == null) {
+ if (incoming == null) return observedLength > 0 ? observedLength : null;
+ return incoming >= observedLength ? incoming : observedLength;
+ }
+ if (incoming == null) {
+ return existing >= observedLength ? existing : observedLength;
+ }
+ final merged = existing >= incoming ? existing : incoming;
+ return merged >= observedLength ? merged : observedLength;
+ }
+
+ List _mergePathVariants(
+ List existing,
+ List incoming,
+ ) {
+ if (incoming.isEmpty) return existing;
+ if (existing.isEmpty) return incoming;
+
+ final merged = [...existing];
+ for (final candidate in incoming) {
+ var already = false;
+ for (final current in merged) {
+ if (_pathsEqual(current, candidate)) {
+ already = true;
+ break;
+ }
+ }
+ if (!already && candidate.isNotEmpty) {
+ merged.add(candidate);
+ }
+ }
+ return merged;
+ }
+
+ bool _pathsEqual(Uint8List a, Uint8List b) {
+ if (a.length != b.length) return false;
+ for (var i = 0; i < a.length; i++) {
+ if (a[i] != b[i]) return false;
+ }
+ return true;
+ }
+
void _handleDisconnection() {
_notifySubscription?.cancel();
_notifySubscription = null;
@@ -1853,8 +2422,11 @@ class MeshCoreConnector extends ChangeNotifier {
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false;
+ _voiceAssemblies.clear();
+ _voiceSendSession = null;
_setState(MeshCoreConnectionState.disconnected);
+ _scheduleReconnect();
}
void _setState(MeshCoreConnectionState newState) {
@@ -1869,6 +2441,7 @@ class MeshCoreConnector extends ChangeNotifier {
_scanSubscription?.cancel();
_connectionSubscription?.cancel();
_notifySubscription?.cancel();
+ _reconnectTimer?.cancel();
_receivedFramesController.close();
super.dispose();
}
@@ -1917,3 +2490,93 @@ class _ParsedText {
required this.text,
});
}
+
+class _VoiceAssembly {
+ _VoiceAssembly({
+ required this.senderKey,
+ required this.senderKeyHex,
+ required this.timestampSeconds,
+ required this.totalChunks,
+ });
+
+ final Uint8List senderKey;
+ final String senderKeyHex;
+ final int timestampSeconds;
+ final int totalChunks;
+ final DateTime startedAt = DateTime.now();
+ final Map _chunks = {};
+
+ bool get isComplete => _chunks.length == totalChunks;
+
+ void addChunk(VoiceChunk chunk) {
+ _chunks.putIfAbsent(chunk.index, () => chunk.bytes);
+ }
+
+ Uint8List assemble() {
+ if (!isComplete) return Uint8List(0);
+ final builder = BytesBuilder(copy: false);
+ for (var i = 0; i < totalChunks; i++) {
+ final part = _chunks[i];
+ if (part == null) return Uint8List(0);
+ builder.add(part);
+ }
+ return builder.takeBytes();
+ }
+}
+
+class _VoiceSendSession {
+ _VoiceSendSession({
+ required this.contact,
+ required this.messageId,
+ required this.chunks,
+ required this.timestampSeconds,
+ });
+
+ final Contact contact;
+ final String messageId;
+ final List chunks;
+ final int timestampSeconds;
+
+ int currentChunkIndex = -1;
+ Uint8List? expectedAckHash;
+ int? expectedTimeoutMs;
+ Completer? sentCompleter;
+ Completer? confirmCompleter;
+ bool _cancelled = false;
+
+ bool get isCancelled => _cancelled;
+
+ void beginChunk(int index) {
+ currentChunkIndex = index;
+ expectedAckHash = null;
+ expectedTimeoutMs = null;
+ sentCompleter = Completer();
+ confirmCompleter = Completer();
+ }
+
+ void handleSent(Uint8List ackHash, int timeoutMs) {
+ if (sentCompleter == null || sentCompleter!.isCompleted) return;
+ expectedAckHash = Uint8List.fromList(ackHash);
+ expectedTimeoutMs = timeoutMs > 0 ? timeoutMs : null;
+ sentCompleter!.complete();
+ }
+
+ void handleConfirmed(Uint8List ackHash) {
+ if (confirmCompleter == null || confirmCompleter!.isCompleted) return;
+ final expected = expectedAckHash;
+ if (expected == null) return;
+ if (!listEquals(expected, ackHash)) return;
+ confirmCompleter!.complete();
+ }
+
+ void cancel() {
+ if (_cancelled) return;
+ _cancelled = true;
+ if (sentCompleter != null && !sentCompleter!.isCompleted) {
+ sentCompleter!.completeError(StateError('cancelled'));
+ }
+ if (confirmCompleter != null && !confirmCompleter!.isCompleted) {
+ confirmCompleter!.completeError(StateError('cancelled'));
+ }
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index ea0e144..f1c7c6d 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:media_kit_fork/media_kit_fork.dart';
import 'connector/meshcore_connector.dart';
import 'screens/scanner_screen.dart';
@@ -9,9 +10,12 @@ import 'services/path_history_service.dart';
import 'services/app_settings_service.dart';
import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
+import 'services/background_service.dart';
+import 'services/map_tile_cache_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
+ MediaKit.ensureInitialized();
// Initialize services
final storage = StorageService();
@@ -20,6 +24,8 @@ void main() async {
final retryService = MessageRetryService(storage);
final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService();
+ final backgroundService = BackgroundService();
+ final mapTileCacheService = MapTileCacheService();
// Load settings
await appSettingsService.loadSettings();
@@ -27,6 +33,7 @@ void main() async {
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
+ await backgroundService.initialize();
// Wire up connector with services
connector.initialize(
@@ -34,6 +41,7 @@ void main() async {
pathHistoryService: pathHistoryService,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
+ backgroundService: backgroundService,
);
await connector.loadContactCache();
@@ -50,6 +58,7 @@ void main() async {
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
+ mapTileCacheService: mapTileCacheService,
));
}
@@ -60,6 +69,7 @@ class MeshCoreApp extends StatelessWidget {
final StorageService storage;
final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService;
+ final MapTileCacheService mapTileCacheService;
const MeshCoreApp({
super.key,
@@ -69,6 +79,7 @@ class MeshCoreApp extends StatelessWidget {
required this.storage,
required this.appSettingsService,
required this.bleDebugLogService,
+ required this.mapTileCacheService,
});
@override
@@ -81,6 +92,7 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
Provider.value(value: storage),
+ Provider.value(value: mapTileCacheService),
],
child: Consumer(
builder: (context, settingsService, child) {
diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart
index 9b64834..e494344 100644
--- a/lib/models/app_settings.dart
+++ b/lib/models/app_settings.dart
@@ -1,4 +1,6 @@
class AppSettings {
+ static const Object _unset = Object();
+
final bool clearPathOnMaxRetry;
final bool mapShowRepeaters;
final bool mapShowChatNodes;
@@ -7,8 +9,12 @@ class AppSettings {
final bool mapKeyPrefixEnabled;
final String mapKeyPrefix;
final bool mapShowMarkers;
+ final Map? mapCacheBounds;
+ final int mapCacheMinZoom;
+ final int mapCacheMaxZoom;
final bool notificationsEnabled;
final bool notifyOnNewMessage;
+ final bool notifyOnNewChannelMessage;
final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled;
final String themeMode;
@@ -23,8 +29,12 @@ class AppSettings {
this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '',
this.mapShowMarkers = true,
+ this.mapCacheBounds,
+ this.mapCacheMinZoom = 10,
+ this.mapCacheMaxZoom = 15,
this.notificationsEnabled = true,
this.notifyOnNewMessage = true,
+ this.notifyOnNewChannelMessage = true,
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.themeMode = 'system',
@@ -41,8 +51,12 @@ class AppSettings {
'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix,
'map_show_markers': mapShowMarkers,
+ 'map_cache_bounds': mapCacheBounds,
+ 'map_cache_min_zoom': mapCacheMinZoom,
+ 'map_cache_max_zoom': mapCacheMaxZoom,
'notifications_enabled': notificationsEnabled,
'notify_on_new_message': notifyOnNewMessage,
+ 'notify_on_new_channel_message': notifyOnNewChannelMessage,
'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'theme_mode': themeMode,
@@ -60,8 +74,15 @@ class AppSettings {
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
+ mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
+ (key, value) => MapEntry(key.toString(), (value as num).toDouble()),
+ ),
+ mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
+ mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
+ notifyOnNewChannelMessage:
+ json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system',
@@ -81,8 +102,12 @@ class AppSettings {
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
bool? mapShowMarkers,
+ Object? mapCacheBounds = _unset,
+ int? mapCacheMinZoom,
+ int? mapCacheMaxZoom,
bool? notificationsEnabled,
bool? notifyOnNewMessage,
+ bool? notifyOnNewChannelMessage,
bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled,
String? themeMode,
@@ -97,8 +122,14 @@ class AppSettings {
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
+ mapCacheBounds:
+ mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map?,
+ mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
+ mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
+ notifyOnNewChannelMessage:
+ notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode,
diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart
index 0fdb679..14d4bf0 100644
--- a/lib/models/channel_message.dart
+++ b/lib/models/channel_message.dart
@@ -32,6 +32,7 @@ class ChannelMessage {
final int repeatCount;
final int? pathLength;
final Uint8List pathBytes;
+ final List pathVariants;
final int? channelIndex;
ChannelMessage({
@@ -45,8 +46,13 @@ class ChannelMessage {
this.repeatCount = 0,
this.pathLength,
Uint8List? pathBytes,
+ List? pathVariants,
this.channelIndex,
- }) : pathBytes = pathBytes ?? Uint8List(0);
+ }) : pathBytes = pathBytes ?? Uint8List(0),
+ pathVariants = _mergePathVariants(
+ pathBytes ?? Uint8List(0),
+ pathVariants,
+ );
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
@@ -56,6 +62,7 @@ class ChannelMessage {
int? repeatCount,
int? pathLength,
Uint8List? pathBytes,
+ List? pathVariants,
}) {
return ChannelMessage(
senderKey: senderKey,
@@ -68,6 +75,7 @@ class ChannelMessage {
repeatCount: repeatCount ?? this.repeatCount,
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
+ pathVariants: pathVariants ?? this.pathVariants,
channelIndex: channelIndex,
);
}
@@ -164,7 +172,39 @@ class ChannelMessage {
status: ChannelMessageStatus.pending,
pathLength: null,
pathBytes: Uint8List(0),
+ pathVariants: const [],
channelIndex: channelIndex,
);
}
+
+ static List _mergePathVariants(
+ Uint8List pathBytes,
+ List? pathVariants,
+ ) {
+ final merged = [];
+
+ void addPath(Uint8List bytes) {
+ if (bytes.isEmpty) return;
+ for (final existing in merged) {
+ if (_pathsEqual(existing, bytes)) return;
+ }
+ merged.add(bytes);
+ }
+
+ if (pathVariants != null) {
+ for (final variant in pathVariants) {
+ addPath(variant);
+ }
+ }
+ addPath(pathBytes);
+ return merged;
+ }
+
+ static bool _pathsEqual(Uint8List a, Uint8List b) {
+ if (a.length != b.length) return false;
+ for (var i = 0; i < a.length; i++) {
+ if (a[i] != b[i]) return false;
+ }
+ return true;
+ }
}
diff --git a/lib/models/contact.dart b/lib/models/contact.dart
index 80e2e8a..1f5003d 100644
--- a/lib/models/contact.dart
+++ b/lib/models/contact.dart
@@ -10,6 +10,7 @@ class Contact {
final double? latitude;
final double? longitude;
final DateTime lastSeen;
+ final DateTime lastMessageAt;
Contact({
required this.publicKey,
@@ -20,7 +21,8 @@ class Contact {
this.latitude,
this.longitude,
required this.lastSeen,
- });
+ DateTime? lastMessageAt,
+ }) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey);
@@ -47,6 +49,30 @@ class Contact {
bool get hasLocation => latitude != null && longitude != null;
+ Contact copyWith({
+ Uint8List? publicKey,
+ String? name,
+ int? type,
+ int? pathLength,
+ Uint8List? path,
+ double? latitude,
+ double? longitude,
+ DateTime? lastSeen,
+ DateTime? lastMessageAt,
+ }) {
+ return Contact(
+ publicKey: publicKey ?? this.publicKey,
+ name: name ?? this.name,
+ type: type ?? this.type,
+ pathLength: pathLength ?? this.pathLength,
+ path: path ?? this.path,
+ latitude: latitude ?? this.latitude,
+ longitude: longitude ?? this.longitude,
+ lastSeen: lastSeen ?? this.lastSeen,
+ lastMessageAt: lastMessageAt ?? this.lastMessageAt,
+ );
+ }
+
String get pathIdList {
if (path.isEmpty) return '';
final parts = [];
diff --git a/lib/models/message.dart b/lib/models/message.dart
index 4c347d4..3ed7354 100644
--- a/lib/models/message.dart
+++ b/lib/models/message.dart
@@ -10,6 +10,10 @@ class Message {
final bool isOutgoing;
final bool isCli;
final MessageStatus status;
+ final bool isVoice;
+ final String? voicePath;
+ final int? voiceDurationMs;
+ final String? voiceCodec;
// NEW: Retry logic fields
final String? messageId;
@@ -30,6 +34,10 @@ class Message {
required this.isOutgoing,
this.isCli = false,
this.status = MessageStatus.pending,
+ this.isVoice = false,
+ this.voicePath,
+ this.voiceDurationMs,
+ this.voiceCodec,
this.messageId,
this.retryCount = 0,
this.estimatedTimeoutMs,
@@ -55,6 +63,10 @@ class Message {
int? pathLength,
Uint8List? pathBytes,
bool? isCli,
+ bool? isVoice,
+ String? voicePath,
+ int? voiceDurationMs,
+ String? voiceCodec,
}) {
return Message(
senderKey: senderKey,
@@ -63,6 +75,10 @@ class Message {
isOutgoing: isOutgoing,
isCli: isCli ?? this.isCli,
status: status ?? this.status,
+ isVoice: isVoice ?? this.isVoice,
+ voicePath: voicePath ?? this.voicePath,
+ voiceDurationMs: voiceDurationMs ?? this.voiceDurationMs,
+ voiceCodec: voiceCodec ?? this.voiceCodec,
messageId: messageId,
retryCount: retryCount ?? this.retryCount,
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
@@ -101,6 +117,7 @@ class Message {
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
+ isVoice: false,
pathBytes: Uint8List(0),
);
}
@@ -118,6 +135,7 @@ class Message {
isOutgoing: true,
isCli: false,
status: MessageStatus.pending,
+ isVoice: false,
pathLength: pathLength,
pathBytes: pathBytes,
);
diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart
index b6ec3a1..746f4b8 100644
--- a/lib/screens/app_settings_screen.dart
+++ b/lib/screens/app_settings_screen.dart
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
+import 'map_cache_screen.dart';
class AppSettingsScreen extends StatelessWidget {
const AppSettingsScreen({super.key});
@@ -133,6 +134,31 @@ class AppSettingsScreen extends StatelessWidget {
: null,
),
const Divider(height: 1),
+ SwitchListTile(
+ secondary: Icon(
+ Icons.forum_outlined,
+ color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
+ ),
+ title: Text(
+ 'Channel Message Notifications',
+ style: TextStyle(
+ color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
+ ),
+ ),
+ subtitle: Text(
+ 'Show notification when receiving channel messages',
+ style: TextStyle(
+ color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
+ ),
+ ),
+ value: settingsService.settings.notifyOnNewChannelMessage,
+ onChanged: settingsService.settings.notificationsEnabled
+ ? (value) {
+ settingsService.setNotifyOnNewChannelMessage(value);
+ }
+ : null,
+ ),
+ const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.cell_tower,
@@ -267,6 +293,24 @@ class AppSettingsScreen extends StatelessWidget {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
),
+ const Divider(height: 1),
+ ListTile(
+ leading: const Icon(Icons.download_outlined),
+ title: const Text('Offline Map Cache'),
+ subtitle: Text(
+ settingsService.settings.mapCacheBounds == null
+ ? 'No area selected'
+ : 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}'
+ '-${settingsService.settings.mapCacheMaxZoom})',
+ ),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const MapCacheScreen()),
+ );
+ },
+ ),
],
),
);
diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart
index ab16b91..b04cab8 100644
--- a/lib/screens/channel_chat_screen.dart
+++ b/lib/screens/channel_chat_screen.dart
@@ -168,6 +168,9 @@ class _ChannelChatScreenState extends State {
final isOutgoing = message.isOutgoing;
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
+ final displayPath = message.pathBytes.isNotEmpty
+ ? message.pathBytes
+ : (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
@@ -223,10 +226,10 @@ class _ChannelChatScreenState extends State {
message.text,
style: const TextStyle(fontSize: 14),
),
- if (message.pathBytes.isNotEmpty) ...[
+ if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
- 'via ${_formatPathPrefixes(message.pathBytes)}',
+ 'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart
index bbd7b97..16ef349 100644
--- a/lib/screens/channel_message_path_screen.dart
+++ b/lib/screens/channel_message_path_screen.dart
@@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
+import '../services/map_tile_cache_service.dart';
import '../connector/meshcore_protocol.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
@@ -23,8 +24,14 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer(
builder: (context, connector, _) {
- final hops = _buildPathHops(message.pathBytes, connector.contacts);
- final hasHopDetails = message.pathBytes.isNotEmpty;
+ final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
+ final hops = _buildPathHops(primaryPath, connector.contacts);
+ final hasHopDetails = primaryPath.isNotEmpty;
+ final observedLabel = _formatObservedHops(
+ primaryPath.length,
+ message.pathLength,
+ );
+ final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold(
appBar: AppBar(
@@ -35,13 +42,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
tooltip: 'View map',
onPressed: hasHopDetails
? () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) =>
- ChannelMessagePathMapScreen(message: message),
- ),
- );
+ _openPathMap(context);
}
: null,
),
@@ -50,8 +51,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
- _buildSummaryCard(context),
+ _buildSummaryCard(context, observedLabel: observedLabel),
const SizedBox(height: 16),
+ if (extraPaths.isNotEmpty) ...[
+ Text(
+ 'Other Observed Paths',
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ const SizedBox(height: 8),
+ _buildPathVariants(context, extraPaths),
+ const SizedBox(height: 16),
+ ],
Text(
'Repeater Hops',
style: Theme.of(context).textTheme.titleSmall,
@@ -71,7 +81,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
- Widget _buildSummaryCard(BuildContext context) {
+ Widget _buildSummaryCard(
+ BuildContext context, {
+ String? observedLabel,
+ }) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -88,12 +101,37 @@ class ChannelMessagePathScreen extends StatelessWidget {
if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
+ if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
],
),
),
);
}
+ Widget _buildPathVariants(
+ BuildContext context,
+ List variants,
+ ) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (int i = 0; i < variants.length; i++)
+ Card(
+ margin: const EdgeInsets.symmetric(vertical: 4),
+ child: ListTile(
+ dense: true,
+ title: Text(
+ 'Observed path ${i + 1} • ${_formatHopCount(variants[i].length)}',
+ ),
+ subtitle: Text(_formatPathPrefixes(variants[i])),
+ trailing: const Icon(Icons.map_outlined, size: 20),
+ onTap: () => _openPathMap(context, initialPath: variants[i]),
+ ),
+ ),
+ ],
+ );
+ }
+
List _buildHopTiles(List<_PathHop> hops) {
return [
for (final hop in hops)
@@ -138,6 +176,22 @@ class ChannelMessagePathScreen extends StatelessWidget {
return '$pathLength hops';
}
+ String? _formatObservedHops(int observedCount, int? pathLength) {
+ if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
+ return null;
+ }
+ if (pathLength == null || pathLength < 0) {
+ return observedCount > 0 ? '$observedCount hops' : null;
+ }
+ if (observedCount == 0) {
+ return '0 of $pathLength hops';
+ }
+ if (observedCount == pathLength) {
+ return '$observedCount hops';
+ }
+ return '$observedCount of $pathLength hops';
+ }
+
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
@@ -153,21 +207,71 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
);
}
+
+ void _openPathMap(BuildContext context, {Uint8List? initialPath}) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => ChannelMessagePathMapScreen(
+ message: message,
+ initialPath: initialPath,
+ ),
+ ),
+ );
+ }
+
}
-class ChannelMessagePathMapScreen extends StatelessWidget {
+class ChannelMessagePathMapScreen extends StatefulWidget {
final ChannelMessage message;
+ final Uint8List? initialPath;
const ChannelMessagePathMapScreen({
super.key,
required this.message,
+ this.initialPath,
});
+ @override
+ State createState() =>
+ _ChannelMessagePathMapScreenState();
+}
+
+class _ChannelMessagePathMapScreenState extends State {
+ Uint8List? _selectedPath;
+
+ @override
+ void initState() {
+ super.initState();
+ _selectedPath = widget.initialPath;
+ }
+
+ @override
+ void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.message != widget.message ||
+ !_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
+ widget.initialPath ?? Uint8List(0))) {
+ _selectedPath = widget.initialPath;
+ }
+ }
+
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, connector, _) {
- final hops = _buildPathHops(message.pathBytes, connector.contacts);
+ final tileCache = context.read();
+ final primaryPath =
+ _selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
+ final observedPaths =
+ _buildObservedPaths(primaryPath, widget.message.pathVariants);
+ final selectedPath = _resolveSelectedPath(
+ _selectedPath,
+ observedPaths,
+ primaryPath,
+ );
+ final selectedIndex = _indexForPath(selectedPath, observedPaths);
+ final hops = _buildPathHops(selectedPath, connector.contacts);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
@@ -186,6 +290,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
points.isNotEmpty ? points.first : const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
+ final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
return Scaffold(
appBar: AppBar(
@@ -194,6 +299,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
body: Stack(
children: [
FlutterMap(
+ key: mapKey,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
@@ -209,9 +315,10 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
),
children: [
TileLayer(
- urlTemplate:
- 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
- userAgentPackageName: 'com.meshcore.open',
+ urlTemplate: kMapTileUrlTemplate,
+ tileProvider: tileCache.tileProvider,
+ userAgentPackageName:
+ MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
@@ -220,6 +327,17 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
),
],
),
+ if (observedPaths.length > 1)
+ _buildPathSelector(
+ context,
+ observedPaths,
+ selectedIndex,
+ (index) {
+ setState(() {
+ _selectedPath = observedPaths[index].pathBytes;
+ });
+ },
+ ),
if (points.isEmpty)
Center(
child: Card(
@@ -238,6 +356,65 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
);
}
+ Widget _buildPathSelector(
+ BuildContext context,
+ List<_ObservedPath> paths,
+ int selectedIndex,
+ ValueChanged onSelected,
+ ) {
+ final selectedPath = paths[selectedIndex];
+ final label = selectedPath.isPrimary
+ ? 'Path ${selectedIndex + 1} (Primary)'
+ : 'Path ${selectedIndex + 1}';
+ return Positioned(
+ left: 16,
+ right: 16,
+ top: 16,
+ child: SafeArea(
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Observed Path',
+ style: TextStyle(fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 4),
+ DropdownButtonHideUnderline(
+ child: DropdownButton(
+ isExpanded: true,
+ value: selectedIndex,
+ items: [
+ for (int i = 0; i < paths.length; i++)
+ DropdownMenuItem(
+ value: i,
+ child: Text(
+ '${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
+ ' • ${_formatHopCount(paths[i].pathBytes.length)}',
+ ),
+ ),
+ ],
+ onChanged: (value) {
+ if (value == null) return;
+ onSelected(value);
+ },
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '$label • ${_formatPathPrefixes(selectedPath.pathBytes)}',
+ style: TextStyle(color: Colors.grey[700], fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
List _buildHopMarkers(List<_PathHop> hops) {
return [
for (final hop in hops)
@@ -356,6 +533,16 @@ class _PathHop {
}
}
+class _ObservedPath {
+ final Uint8List pathBytes;
+ final bool isPrimary;
+
+ const _ObservedPath({
+ required this.pathBytes,
+ required this.isPrimary,
+ });
+}
+
List<_PathHop> _buildPathHops(Uint8List pathBytes, List contacts) {
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
@@ -375,7 +562,10 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List contacts) {
Contact? _matchContactForPrefix(List contacts, int prefix) {
final matches = contacts
- .where((contact) => contact.publicKey.isNotEmpty && contact.publicKey[0] == prefix)
+ .where((contact) =>
+ (contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
+ contact.publicKey.isNotEmpty &&
+ contact.publicKey[0] == prefix)
.toList();
if (matches.isEmpty) return null;
@@ -410,6 +600,16 @@ String _formatPrefix(int prefix) {
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
}
+String _formatPathPrefixes(Uint8List pathBytes) {
+ return pathBytes
+ .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
+ .join(',');
+}
+
+String _formatHopCount(int count) {
+ return '$count ${count == 1 ? 'hop' : 'hops'}';
+}
+
String _resolveName(Contact? contact) {
if (contact == null) return 'Unknown Repeater';
final name = contact.name.trim();
@@ -418,3 +618,81 @@ String _resolveName(Contact? contact) {
}
return name;
}
+
+Uint8List _selectPrimaryPath(Uint8List pathBytes, List variants) {
+ Uint8List primary = pathBytes;
+ for (final variant in variants) {
+ if (variant.length > primary.length) {
+ primary = variant;
+ }
+ }
+ return primary;
+}
+
+List _otherPaths(Uint8List primary, List variants) {
+ final others = [];
+ for (final variant in variants) {
+ if (variant.isEmpty) continue;
+ if (!_pathsEqual(primary, variant)) {
+ others.add(variant);
+ }
+ }
+ return others;
+}
+
+List<_ObservedPath> _buildObservedPaths(
+ Uint8List primary,
+ List variants,
+) {
+ final observed = <_ObservedPath>[];
+
+ void addPath(Uint8List pathBytes, bool isPrimary) {
+ if (pathBytes.isEmpty) return;
+ for (final existing in observed) {
+ if (_pathsEqual(existing.pathBytes, pathBytes)) return;
+ }
+ observed.add(_ObservedPath(pathBytes: pathBytes, isPrimary: isPrimary));
+ }
+
+ addPath(primary, true);
+ for (final variant in variants) {
+ addPath(variant, false);
+ }
+
+ return observed;
+}
+
+Uint8List _resolveSelectedPath(
+ Uint8List? selected,
+ List<_ObservedPath> observedPaths,
+ Uint8List fallback,
+) {
+ if (selected != null) {
+ for (final path in observedPaths) {
+ if (_pathsEqual(path.pathBytes, selected)) {
+ return path.pathBytes;
+ }
+ }
+ }
+ if (observedPaths.isNotEmpty) {
+ return observedPaths.first.pathBytes;
+ }
+ return fallback;
+}
+
+int _indexForPath(Uint8List selected, List<_ObservedPath> paths) {
+ for (int i = 0; i < paths.length; i++) {
+ if (_pathsEqual(paths[i].pathBytes, selected)) {
+ return i;
+ }
+ }
+ return 0;
+}
+
+bool _pathsEqual(Uint8List a, Uint8List b) {
+ if (a.length != b.length) return false;
+ for (var i = 0; i < a.length; i++) {
+ if (a[i] != b[i]) return false;
+ }
+ return true;
+}
diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart
index 145a639..051d584 100644
--- a/lib/screens/channels_screen.dart
+++ b/lib/screens/channels_screen.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
@@ -6,11 +7,21 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
+import '../utils/route_transitions.dart';
+import '../widgets/quick_switch_bar.dart';
import '../widgets/unread_badge.dart';
import 'channel_chat_screen.dart';
+import 'contacts_screen.dart';
+import 'map_screen.dart';
+import 'settings_screen.dart';
class ChannelsScreen extends StatefulWidget {
- const ChannelsScreen({super.key});
+ final bool hideBackButton;
+
+ const ChannelsScreen({
+ super.key,
+ this.hideBackButton = false,
+ });
@override
State createState() => _ChannelsScreenState();
@@ -31,7 +42,21 @@ class _ChannelsScreenState extends State {
appBar: AppBar(
title: const Text('Channels'),
centerTitle: true,
+ automaticallyImplyLeading: !widget.hideBackButton,
actions: [
+ IconButton(
+ icon: const Icon(Icons.tune),
+ tooltip: 'Settings',
+ onPressed: () => Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const SettingsScreen()),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.bluetooth_disabled),
+ tooltip: 'Disconnect',
+ onPressed: () => _disconnect(context),
+ ),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read().getChannels(),
@@ -69,20 +94,23 @@ class _ChannelsScreenState extends State {
}
return ReorderableListView.builder(
- padding: const EdgeInsets.all(8),
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
+ buildDefaultDragHandles: false,
itemCount: channels.length,
- onReorder: (oldIndex, newIndex) async {
+ onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
- await connector.setChannelOrder(
- reordered.map((c) => c.index).toList(),
+ unawaited(
+ connector.setChannelOrder(
+ reordered.map((c) => c.index).toList(),
+ ),
);
},
itemBuilder: (context, index) {
final channel = channels[index];
- return _buildChannelTile(context, connector, channel);
+ return _buildChannelTile(context, connector, channel, index);
},
);
},
@@ -91,6 +119,13 @@ class _ChannelsScreenState extends State {
onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add),
),
+ bottomNavigationBar: SafeArea(
+ top: false,
+ child: QuickSwitchBar(
+ selectedIndex: 1,
+ onDestinationSelected: (index) => _handleQuickSwitch(index, context),
+ ),
+ ),
);
}
@@ -98,11 +133,17 @@ class _ChannelsScreenState extends State {
BuildContext context,
MeshCoreConnector connector,
Channel channel,
+ int index,
) {
final unreadCount = connector.getUnreadCountForChannel(channel);
return Card(
key: ValueKey('channel_${channel.index}'),
+ margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
+ dense: true,
+ minVerticalPadding: 0,
+ contentPadding: const EdgeInsets.symmetric(horizontal: 12),
+ visualDensity: const VisualDensity(vertical: -2),
leading: CircleAvatar(
backgroundColor: channel.isPublicChannel
? Colors.green.withValues(alpha: 0.2)
@@ -120,36 +161,26 @@ class _ChannelsScreenState extends State {
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
- subtitle: Text(
- channel.name.startsWith('#')
- ? 'Hashtag channel'
- : channel.isPublicChannel
- ? 'Public channel'
- : 'Private channel',
- ),
+ subtitle: Text(
+ channel.name.startsWith('#')
+ ? 'Hashtag channel'
+ : channel.isPublicChannel
+ ? 'Public channel'
+ : 'Private channel',
+ ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
- const SizedBox(width: 8),
+ const SizedBox(width: 4),
],
- IconButton(
- icon: const Icon(Icons.edit_outlined),
- onPressed: () => _showEditChannelDialog(context, connector, channel),
- ),
- PopupMenuButton(
- onSelected: (value) {
- if (value == 'delete') {
- _confirmDeleteChannel(context, connector, channel);
- }
- },
- itemBuilder: (context) => [
- const PopupMenuItem(
- value: 'delete',
- child: Text('Delete'),
- ),
- ],
+ ReorderableDelayedDragStartListener(
+ index: index,
+ child: Icon(
+ Icons.drag_handle,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
),
],
),
@@ -162,10 +193,91 @@ class _ChannelsScreenState extends State {
),
);
},
+ onLongPress: () => _showChannelActions(context, connector, channel),
),
);
}
+ void _showChannelActions(
+ BuildContext context,
+ MeshCoreConnector connector,
+ Channel channel,
+ ) {
+ showModalBottomSheet(
+ context: context,
+ builder: (context) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.edit_outlined),
+ title: const Text('Edit channel'),
+ onTap: () {
+ Navigator.pop(context);
+ _showEditChannelDialog(context, connector, channel);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.delete_outline, color: Colors.red),
+ title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
+ onTap: () {
+ Navigator.pop(context);
+ _confirmDeleteChannel(context, connector, channel);
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ void _handleQuickSwitch(int index, BuildContext context) {
+ if (index == 1) return;
+ switch (index) {
+ case 0:
+ Navigator.pushReplacement(
+ context,
+ buildQuickSwitchRoute(
+ const ContactsScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ case 2:
+ Navigator.pushReplacement(
+ context,
+ buildQuickSwitchRoute(
+ const MapScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ }
+ }
+
+ Future _disconnect(BuildContext context) async {
+ final connector = context.read();
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Disconnect'),
+ content: const Text('Are you sure you want to disconnect from this device?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Disconnect'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true) {
+ await connector.disconnect();
+ }
+ }
+
void _showAddChannelDialog(BuildContext context) {
final connector = context.read();
final nameController = TextEditingController();
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 2509eb3..1cf33cc 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -1,10 +1,13 @@
+import 'dart:async';
import 'dart:convert';
+import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
+import 'package:record/record.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@@ -12,12 +15,14 @@ import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
+import '../services/voice_message_service.dart';
import '../services/path_history_service.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
+import '../widgets/voice_message.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@@ -32,6 +37,16 @@ class _ChatScreenState extends State {
final _textController = TextEditingController();
final _scrollController = ScrollController();
bool _forceFlood = false;
+ final AudioRecorder _voiceRecorder = AudioRecorder();
+ StreamSubscription? _voiceStreamSubscription;
+ BytesBuilder _voiceBuffer = BytesBuilder(copy: false);
+ Timer? _voiceRecordTimer;
+ bool _isRecordingVoice = false;
+ Message? _pendingVoiceMessage;
+ Uint8List? _pendingVoiceCodec2Bytes;
+ int? _pendingVoiceTimestampSeconds;
+ int? _pendingVoiceDurationMs;
+ String? _pendingVoicePath;
@override
void initState() {
@@ -47,6 +62,11 @@ class _ChatScreenState extends State {
context.read().setActiveContact(null);
_textController.dispose();
_scrollController.dispose();
+ _voiceRecordTimer?.cancel();
+ _voiceStreamSubscription?.cancel();
+ unawaited(_voiceRecorder.stop());
+ _voiceRecorder.dispose();
+ unawaited(_clearPendingVoicePreview(deleteFile: true, notify: false));
super.dispose();
}
@@ -56,35 +76,29 @@ class _ChatScreenState extends State {
appBar: AppBar(
title: Consumer2(
builder: (context, pathService, connector, _) {
- final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
final contact = _resolveContact(connector);
- final showRecentPath = paths.isNotEmpty && contact.pathLength >= 0;
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount';
+ final pathLabel = _forceFlood ? 'Flood (forced)' : _currentPathLabel(contact);
+ final canShowPathDetails = !_forceFlood && contact.path.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(contact.name),
- if (showRecentPath)
+ if (canShowPathDetails)
GestureDetector(
behavior: HitTestBehavior.opaque,
- onLongPress: () => _showFullPathDialog(context, paths.first.pathBytes),
+ onLongPress: () => _showFullPathDialog(context, contact.path),
child: Text(
- '${paths.first.displayText} • $unreadLabel',
+ '$pathLabel • $unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
)
- else if (contact.pathLength >= 0)
- Text(
- '${contact.pathLength} ${contact.pathLength == 1 ? 'hop' : 'hops'} • $unreadLabel',
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
- )
else
Text(
- 'No path • $unreadLabel',
+ '$pathLabel • $unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
@@ -207,10 +221,14 @@ class _ChatScreenState extends State {
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
+ final isVoiceBusy = connector.isVoiceSending;
+ final voiceSupported = Platform.isAndroid || Platform.isIOS;
+ final hasPendingVoice = _pendingVoiceMessage != null;
+ final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surface,
+ color: colorScheme.surface,
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
@@ -218,59 +236,93 @@ class _ChatScreenState extends State {
child: SafeArea(
child: Row(
children: [
+ if (voiceSupported)
+ IconButton(
+ icon: Icon(_isRecordingVoice ? Icons.stop_circle : Icons.mic),
+ onPressed: (isVoiceBusy || hasPendingVoice) ? null : () => _toggleVoiceRecording(connector),
+ tooltip: _isRecordingVoice ? 'Stop recording' : 'Record voice',
+ ),
IconButton(
icon: const Icon(Icons.gif_box),
- onPressed: () => _showGifPicker(context),
+ onPressed: (_isRecordingVoice || isVoiceBusy || hasPendingVoice)
+ ? null
+ : () => _showGifPicker(context),
tooltip: 'Send GIF',
),
Expanded(
- child: ValueListenableBuilder(
- valueListenable: _textController,
- builder: (context, value, child) {
- final gifId = _parseGifId(value.text);
- if (gifId != null) {
- return Row(
- children: [
- Expanded(
- child: GifMessage(
- url: 'https://media.giphy.com/media/$gifId/giphy.gif',
- backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
- fallbackTextColor:
- Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
- width: 160,
- height: 110,
- ),
- ),
- const SizedBox(width: 8),
- IconButton(
- icon: const Icon(Icons.close),
- onPressed: () => _textController.clear(),
- ),
- ],
- );
- }
+ child: hasPendingVoice
+ ? _buildVoicePreview(colorScheme)
+ : ValueListenableBuilder(
+ valueListenable: _textController,
+ builder: (context, value, child) {
+ final gifId = _parseGifId(value.text);
+ if (gifId != null) {
+ return Row(
+ children: [
+ Expanded(
+ child: GifMessage(
+ url: 'https://media.giphy.com/media/$gifId/giphy.gif',
+ backgroundColor: colorScheme.surfaceContainerHighest,
+ fallbackTextColor:
+ colorScheme.onSurface.withValues(alpha: 0.6),
+ width: 160,
+ height: 110,
+ ),
+ ),
+ const SizedBox(width: 8),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => _textController.clear(),
+ ),
+ ],
+ );
+ }
- return TextField(
- controller: _textController,
- inputFormatters: [
- Utf8LengthLimitingTextInputFormatter(maxBytes),
- ],
- decoration: const InputDecoration(
- hintText: 'Type a message...',
- border: OutlineInputBorder(),
- contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ return TextField(
+ controller: _textController,
+ enabled: !_isRecordingVoice && !isVoiceBusy,
+ inputFormatters: [
+ Utf8LengthLimitingTextInputFormatter(maxBytes),
+ ],
+ decoration: const InputDecoration(
+ hintText: 'Type a message...',
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ ),
+ textInputAction: TextInputAction.send,
+ onSubmitted: (_isRecordingVoice || isVoiceBusy)
+ ? null
+ : (_) => _sendMessage(connector),
+ );
+ },
),
- textInputAction: TextInputAction.send,
- onSubmitted: (_) => _sendMessage(connector),
- );
- },
- ),
),
const SizedBox(width: 8),
- IconButton.filled(
- icon: const Icon(Icons.send),
- onPressed: () => _sendMessage(connector),
- ),
+ if (isVoiceBusy)
+ IconButton.filled(
+ icon: const Icon(Icons.stop_circle),
+ onPressed: () => _cancelVoiceSend(connector),
+ tooltip: 'Cancel voice send',
+ )
+ else if (hasPendingVoice) ...[
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => _clearPendingVoicePreview(deleteFile: true),
+ tooltip: 'Discard voice message',
+ ),
+ IconButton.filled(
+ icon: const Icon(Icons.send),
+ onPressed: () => _sendPendingVoice(connector),
+ tooltip: 'Send voice message',
+ ),
+ ]
+ else
+ IconButton.filled(
+ icon: const Icon(Icons.send),
+ onPressed: (_isRecordingVoice || isVoiceBusy)
+ ? null
+ : () => _sendMessage(connector),
+ ),
],
),
),
@@ -325,6 +377,209 @@ class _ChatScreenState extends State {
});
}
+ void _cancelVoiceSend(MeshCoreConnector connector) {
+ connector.cancelVoiceSend();
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Voice send canceled')),
+ );
+ }
+
+ Future _toggleVoiceRecording(MeshCoreConnector connector) async {
+ if (_isRecordingVoice) {
+ await _stopVoiceRecording(connector);
+ } else {
+ await _startVoiceRecording();
+ }
+ }
+
+ Future _startVoiceRecording() async {
+ if (_isRecordingVoice) return;
+ final hasPermission = await _voiceRecorder.hasPermission();
+ if (!hasPermission) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Microphone permission denied')),
+ );
+ return;
+ }
+
+ _voiceBuffer = BytesBuilder(copy: false);
+ try {
+ final stream = await _voiceRecorder.startStream(
+ const RecordConfig(
+ encoder: AudioEncoder.pcm16bits,
+ sampleRate: VoiceMessageService.sampleRate,
+ numChannels: VoiceMessageService.channels,
+ ),
+ );
+ _voiceStreamSubscription = stream.listen((data) {
+ _voiceBuffer.add(data);
+ });
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to start recording: $e')),
+ );
+ return;
+ }
+ _voiceRecordTimer?.cancel();
+ _voiceRecordTimer = Timer(
+ const Duration(seconds: VoiceMessageService.maxRecordSeconds),
+ () => _stopVoiceRecording(context.read()),
+ );
+ setState(() {
+ _isRecordingVoice = true;
+ });
+ }
+
+ Future _stopVoiceRecording(MeshCoreConnector connector) async {
+ if (!_isRecordingVoice) return;
+ _voiceRecordTimer?.cancel();
+ await _voiceRecorder.stop();
+ await _voiceStreamSubscription?.cancel();
+ _voiceStreamSubscription = null;
+ final pcmBytes = _voiceBuffer.takeBytes();
+ setState(() {
+ _isRecordingVoice = false;
+ });
+ if (pcmBytes.isEmpty) return;
+ await _prepareVoicePreview(connector, pcmBytes);
+ }
+
+ Future _prepareVoicePreview(MeshCoreConnector connector, Uint8List pcmBytes) async {
+ final voiceService = VoiceMessageService.instance;
+ try {
+ final codec2Bytes = voiceService.encodePcmToCodec2(pcmBytes);
+ if (codec2Bytes.isEmpty) return;
+ final timestampSeconds = connector.reserveVoiceTimestampSeconds();
+ final durationMs = voiceService.durationMsForCodec2Bytes(codec2Bytes);
+ final decodedPcm = voiceService.decodeCodec2ToPcm(codec2Bytes);
+ final fileName = voiceService.buildVoiceFileName(
+ senderKeyHex: widget.contact.publicKeyHex,
+ timestampSeconds: timestampSeconds,
+ outgoing: true,
+ );
+ final voicePath = await voiceService.writeWavFile(
+ pcmBytes: decodedPcm,
+ fileName: fileName,
+ );
+
+ final previewMessage = Message(
+ senderKey: widget.contact.publicKey,
+ text: 'Voice message',
+ timestamp: DateTime.fromMillisecondsSinceEpoch(timestampSeconds * 1000),
+ isOutgoing: true,
+ isCli: false,
+ status: MessageStatus.pending,
+ isVoice: true,
+ voicePath: voicePath,
+ voiceDurationMs: durationMs,
+ voiceCodec: VoiceMessageService.codecName,
+ );
+
+ if (!mounted) return;
+ setState(() {
+ _pendingVoiceMessage = previewMessage;
+ _pendingVoiceCodec2Bytes = codec2Bytes;
+ _pendingVoiceTimestampSeconds = timestampSeconds;
+ _pendingVoiceDurationMs = durationMs;
+ _pendingVoicePath = voicePath;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Voice message failed: $e')),
+ );
+ }
+ }
+
+ Widget _buildVoicePreview(ColorScheme colorScheme) {
+ final message = _pendingVoiceMessage;
+ if (message == null) {
+ return const SizedBox.shrink();
+ }
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: VoiceMessageBubble(
+ message: message,
+ backgroundColor: colorScheme.surfaceContainerHighest,
+ textColor: colorScheme.onSurface,
+ metaColor: colorScheme.onSurface.withValues(alpha: 0.7),
+ isOutgoing: true,
+ ),
+ );
+ }
+
+ Future _sendPendingVoice(MeshCoreConnector connector) async {
+ final codec2Bytes = _pendingVoiceCodec2Bytes;
+ final voicePath = _pendingVoicePath;
+ final durationMs = _pendingVoiceDurationMs;
+ final timestampSeconds = _pendingVoiceTimestampSeconds;
+
+ if (codec2Bytes == null ||
+ codec2Bytes.isEmpty ||
+ voicePath == null ||
+ voicePath.isEmpty ||
+ durationMs == null ||
+ timestampSeconds == null) {
+ return;
+ }
+ if (!connector.isConnected) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Not connected to a MeshCore device')),
+ );
+ return;
+ }
+ if (connector.isVoiceSending) {
+ return;
+ }
+
+ await connector.sendVoiceMessage(
+ contact: widget.contact,
+ codec2Bytes: codec2Bytes,
+ voicePath: voicePath,
+ durationMs: durationMs,
+ timestampSeconds: timestampSeconds,
+ );
+ unawaited(_clearPendingVoicePreview(deleteFile: false));
+ }
+
+ Future _clearPendingVoicePreview({required bool deleteFile, bool notify = true}) async {
+ final path = _pendingVoicePath;
+ if (notify && mounted) {
+ setState(() {
+ _pendingVoiceMessage = null;
+ _pendingVoiceCodec2Bytes = null;
+ _pendingVoiceTimestampSeconds = null;
+ _pendingVoiceDurationMs = null;
+ _pendingVoicePath = null;
+ });
+ } else {
+ _pendingVoiceMessage = null;
+ _pendingVoiceCodec2Bytes = null;
+ _pendingVoiceTimestampSeconds = null;
+ _pendingVoiceDurationMs = null;
+ _pendingVoicePath = null;
+ }
+ if (deleteFile && path != null && path.isNotEmpty) {
+ try {
+ final file = File(path);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (_) {
+ return;
+ }
+ }
+ }
+
void _showPathHistory(BuildContext context) {
final connector = Provider.of(context, listen: false);
@@ -1024,14 +1279,15 @@ class _ChatScreenState extends State {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- ListTile(
- leading: const Icon(Icons.copy),
- title: const Text('Copy'),
- onTap: () {
- Navigator.pop(sheetContext);
- _copyMessageText(message.text);
- },
- ),
+ if (!message.isVoice)
+ ListTile(
+ leading: const Icon(Icons.copy),
+ title: const Text('Copy'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _copyMessageText(message.text);
+ },
+ ),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
@@ -1040,7 +1296,9 @@ class _ChatScreenState extends State {
await _deleteMessage(message);
},
),
- if (message.isOutgoing && message.status == MessageStatus.failed)
+ if (message.isOutgoing &&
+ message.status == MessageStatus.failed &&
+ !message.isVoice)
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
@@ -1154,7 +1412,15 @@ class _MessageBubble extends StatelessWidget {
),
const SizedBox(height: 4),
],
- if (poi != null)
+ if (message.isVoice)
+ VoiceMessageBubble(
+ message: message,
+ backgroundColor: bubbleColor,
+ textColor: textColor,
+ metaColor: metaColor,
+ isOutgoing: isOutgoing,
+ )
+ else if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
GifMessage(
diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart
index 8425725..79ee0ff 100644
--- a/lib/screens/contacts_screen.dart
+++ b/lib/screens/contacts_screen.dart
@@ -6,11 +6,17 @@ import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
+import '../utils/contact_search.dart';
+import '../utils/emoji_utils.dart';
+import '../utils/route_transitions.dart';
+import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/unread_badge.dart';
-import '../utils/emoji_utils.dart';
+import 'channels_screen.dart';
import 'chat_screen.dart';
+import 'map_screen.dart';
import 'repeater_hub_screen.dart';
+import 'settings_screen.dart';
enum ContactSortOption {
lastSeen,
@@ -19,8 +25,22 @@ enum ContactSortOption {
type,
}
+enum _ContactMenuAction {
+ sortRecentMessages,
+ sortName,
+ sortType,
+ toggleLastSeenFilter,
+ toggleUnreadOnly,
+ newGroup,
+}
+
class ContactsScreen extends StatefulWidget {
- const ContactsScreen({super.key});
+ final bool hideBackButton;
+
+ const ContactsScreen({
+ super.key,
+ this.hideBackButton = false,
+ });
@override
State createState() => _ContactsScreenState();
@@ -30,6 +50,7 @@ class _ContactsScreenState extends State {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
+ bool _forceLastSeenSort = true;
bool _showUnreadOnly = false;
final ContactGroupStore _groupStore = ContactGroupStore();
List _groups = [];
@@ -60,275 +81,309 @@ class _ContactsScreenState extends State {
@override
Widget build(BuildContext context) {
+ final connector = context.watch();
+
+ if (!connector.isConnected) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (context.mounted) {
+ Navigator.popUntil(context, (route) => route.isFirst);
+ }
+ });
+ }
+
+ final theme = Theme.of(context);
+
return Scaffold(
appBar: AppBar(
- title: const Text('Contacts'),
- centerTitle: true,
- actions: [
- PopupMenuButton(
- icon: const Icon(Icons.sort),
- tooltip: 'Sort by',
- onSelected: (option) {
- setState(() {
- _sortOption = option;
- });
- },
- itemBuilder: (context) => [
- PopupMenuItem(
- value: ContactSortOption.lastSeen,
- child: Row(
- children: [
- Icon(
- Icons.access_time,
- size: 20,
- color: _sortOption == ContactSortOption.lastSeen
- ? Theme.of(context).primaryColor
- : null,
- ),
- const SizedBox(width: 12),
- Text(
- 'Last Seen',
- style: TextStyle(
- fontWeight: _sortOption == ContactSortOption.lastSeen
- ? FontWeight.bold
- : FontWeight.normal,
- ),
- ),
- ],
- ),
+ titleSpacing: 16,
+ centerTitle: false,
+ automaticallyImplyLeading: !widget.hideBackButton,
+ title: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text('Contacts'),
+ Text(
+ '${connector.contacts.length} contacts',
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
),
- PopupMenuItem(
- value: ContactSortOption.recentMessages,
- child: Row(
- children: [
- Icon(
- Icons.chat_bubble,
- size: 20,
- color: _sortOption == ContactSortOption.recentMessages
- ? Theme.of(context).primaryColor
- : null,
- ),
- const SizedBox(width: 12),
- Text(
- 'Recent Messages',
- style: TextStyle(
- fontWeight: _sortOption == ContactSortOption.recentMessages
- ? FontWeight.bold
- : FontWeight.normal,
- ),
- ),
- ],
- ),
- ),
- PopupMenuItem(
- value: ContactSortOption.name,
- child: Row(
- children: [
- Icon(
- Icons.sort_by_alpha,
- size: 20,
- color: _sortOption == ContactSortOption.name
- ? Theme.of(context).primaryColor
- : null,
- ),
- const SizedBox(width: 12),
- Text(
- 'Name',
- style: TextStyle(
- fontWeight: _sortOption == ContactSortOption.name
- ? FontWeight.bold
- : FontWeight.normal,
- ),
- ),
- ],
- ),
- ),
- PopupMenuItem(
- value: ContactSortOption.type,
- child: Row(
- children: [
- Icon(
- Icons.category,
- size: 20,
- color: _sortOption == ContactSortOption.type
- ? Theme.of(context).primaryColor
- : null,
- ),
- const SizedBox(width: 12),
- Text(
- 'Type',
- style: TextStyle(
- fontWeight: _sortOption == ContactSortOption.type
- ? FontWeight.bold
- : FontWeight.normal,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- IconButton(
- icon: Icon(
- Icons.mark_chat_unread_outlined,
- color: _showUnreadOnly ? Theme.of(context).primaryColor : null,
),
- tooltip: _showUnreadOnly ? 'Showing unread only' : 'Show unread only',
- onPressed: () {
- setState(() {
- _showUnreadOnly = !_showUnreadOnly;
- });
- },
+ ],
+ ),
+ actions: [
+ IconButton(
+ icon: connector.isLoadingContacts
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.refresh),
+ tooltip: 'Refresh',
+ onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
),
IconButton(
- icon: const Icon(Icons.group_add),
- tooltip: 'New group',
- onPressed: () {
- final contacts = context.read().contacts;
- _showGroupEditor(context, contacts);
- },
+ icon: const Icon(Icons.bluetooth_disabled),
+ tooltip: 'Disconnect',
+ onPressed: () => _disconnect(context, connector),
),
- Consumer(
- builder: (context, connector, child) {
- return IconButton(
- icon: connector.isLoadingContacts
- ? const SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Icon(Icons.refresh),
- onPressed: connector.isLoadingContacts
- ? null
- : () => connector.getContacts(),
+ IconButton(
+ icon: const Icon(Icons.tune),
+ tooltip: 'Settings',
+ onPressed: () => Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const SettingsScreen()),
+ ),
+ ),
+ PopupMenuButton<_ContactMenuAction>(
+ tooltip: 'Contacts options',
+ onSelected: (action) {
+ switch (action) {
+ case _ContactMenuAction.sortRecentMessages:
+ setState(() {
+ _sortOption = ContactSortOption.recentMessages;
+ _forceLastSeenSort = false;
+ });
+ break;
+ case _ContactMenuAction.sortName:
+ setState(() {
+ _sortOption = ContactSortOption.name;
+ _forceLastSeenSort = false;
+ });
+ break;
+ case _ContactMenuAction.sortType:
+ setState(() {
+ _sortOption = ContactSortOption.type;
+ _forceLastSeenSort = false;
+ });
+ break;
+ case _ContactMenuAction.toggleLastSeenFilter:
+ setState(() {
+ _forceLastSeenSort = !_forceLastSeenSort;
+ if (_forceLastSeenSort) {
+ _sortOption = ContactSortOption.lastSeen;
+ }
+ });
+ break;
+ case _ContactMenuAction.toggleUnreadOnly:
+ setState(() {
+ _showUnreadOnly = !_showUnreadOnly;
+ });
+ break;
+ case _ContactMenuAction.newGroup:
+ _showGroupEditor(context, connector.contacts);
+ break;
+ }
+ },
+ itemBuilder: (context) {
+ final labelStyle = theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
);
+ return [
+ PopupMenuItem<_ContactMenuAction>(
+ enabled: false,
+ child: Text('Sort by', style: labelStyle),
+ ),
+ CheckedPopupMenuItem<_ContactMenuAction>(
+ value: _ContactMenuAction.sortRecentMessages,
+ checked: _sortOption == ContactSortOption.recentMessages,
+ child: const Text('Recent messages'),
+ ),
+ CheckedPopupMenuItem<_ContactMenuAction>(
+ value: _ContactMenuAction.sortName,
+ checked: _sortOption == ContactSortOption.name,
+ child: const Text('Name'),
+ ),
+ CheckedPopupMenuItem<_ContactMenuAction>(
+ value: _ContactMenuAction.sortType,
+ checked: _sortOption == ContactSortOption.type,
+ child: const Text('Type'),
+ ),
+ const PopupMenuDivider(),
+ PopupMenuItem<_ContactMenuAction>(
+ enabled: false,
+ child: Text('Filters', style: labelStyle),
+ ),
+ CheckedPopupMenuItem<_ContactMenuAction>(
+ value: _ContactMenuAction.toggleLastSeenFilter,
+ checked: _forceLastSeenSort,
+ child: const Text('Last seen'),
+ ),
+ CheckedPopupMenuItem<_ContactMenuAction>(
+ value: _ContactMenuAction.toggleUnreadOnly,
+ checked: _showUnreadOnly,
+ child: const Text('Unread only'),
+ ),
+ PopupMenuItem<_ContactMenuAction>(
+ value: _ContactMenuAction.newGroup,
+ child: const Text('New group'),
+ ),
+ ];
},
),
],
),
- body: Consumer(
- builder: (context, connector, child) {
- final contacts = connector.contacts;
-
- if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
- return const Center(child: CircularProgressIndicator());
- }
-
- if (contacts.isEmpty && _groups.isEmpty) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
- const SizedBox(height: 16),
- Text(
- 'No contacts yet',
- style: TextStyle(fontSize: 16, color: Colors.grey[600]),
- ),
- const SizedBox(height: 8),
- Text(
- 'Contacts will appear when devices advertise',
- style: TextStyle(fontSize: 14, color: Colors.grey[500]),
- ),
- ],
- ),
- );
- }
-
- final filteredAndSorted = _filterAndSortContacts(contacts, connector);
- final filteredGroups =
- _showUnreadOnly ? const [] : _filterAndSortGroups(_groups, contacts);
-
- return Column(
- children: [
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: TextField(
- controller: _searchController,
- decoration: InputDecoration(
- hintText: 'Search contacts...',
- prefixIcon: const Icon(Icons.search),
- suffixIcon: _searchQuery.isNotEmpty
- ? IconButton(
- icon: const Icon(Icons.clear),
- onPressed: () {
- _searchController.clear();
- setState(() {
- _searchQuery = '';
- });
- },
- )
- : null,
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(12),
- ),
- contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- ),
- onChanged: (value) {
- setState(() {
- _searchQuery = value.toLowerCase();
- });
- },
- ),
- ),
- Expanded(
- child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
- ? Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
- const SizedBox(height: 16),
- Text(
- _showUnreadOnly
- ? 'No unread contacts'
- : 'No contacts or groups found',
- style: TextStyle(fontSize: 16, color: Colors.grey[600]),
- ),
- ],
- ),
- )
- : RefreshIndicator(
- onRefresh: () => connector.getContacts(),
- child: ListView.builder(
- itemCount: filteredGroups.length + filteredAndSorted.length,
- itemBuilder: (context, index) {
- if (index < filteredGroups.length) {
- final group = filteredGroups[index];
- return _buildGroupTile(context, group, contacts);
- }
- final contact = filteredAndSorted[index - filteredGroups.length];
- final unreadCount = connector.getUnreadCountForContact(contact);
- return _ContactTile(
- contact: contact,
- unreadCount: unreadCount,
- onTap: () => _openChat(context, contact),
- onLongPress: () => _showContactOptions(context, connector, contact),
- );
- },
- ),
- ),
- ),
- ],
- );
- },
+ body: _buildContactsBody(context, connector),
+ bottomNavigationBar: SafeArea(
+ top: false,
+ child: QuickSwitchBar(
+ selectedIndex: 0,
+ onDestinationSelected: (index) => _handleQuickSwitch(index, context),
+ ),
),
);
}
+ Future _disconnect(
+ BuildContext context,
+ MeshCoreConnector connector,
+ ) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Disconnect'),
+ content: const Text('Are you sure you want to disconnect from this device?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Disconnect'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true) {
+ await connector.disconnect();
+ }
+ }
+
+ Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
+ final contacts = connector.contacts;
+
+ if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (contacts.isEmpty && _groups.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
+ const SizedBox(height: 16),
+ Text(
+ 'No contacts yet',
+ style: TextStyle(fontSize: 16, color: Colors.grey[600]),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Contacts will appear when devices advertise',
+ style: TextStyle(fontSize: 14, color: Colors.grey[500]),
+ ),
+ ],
+ ),
+ );
+ }
+
+ final filteredAndSorted = _filterAndSortContacts(contacts, connector);
+ final filteredGroups =
+ _showUnreadOnly ? const [] : _filterAndSortGroups(_groups, contacts);
+
+ return Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: TextField(
+ controller: _searchController,
+ decoration: InputDecoration(
+ hintText: 'Search contacts...',
+ prefixIcon: const Icon(Icons.search),
+ suffixIcon: _searchQuery.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ _searchController.clear();
+ setState(() {
+ _searchQuery = '';
+ });
+ },
+ )
+ : null,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ ),
+ onChanged: (value) {
+ setState(() {
+ _searchQuery = value.toLowerCase();
+ });
+ },
+ ),
+ ),
+ Expanded(
+ child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
+ ? Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
+ const SizedBox(height: 16),
+ Text(
+ _showUnreadOnly
+ ? 'No unread contacts'
+ : 'No contacts or groups found',
+ style: TextStyle(fontSize: 16, color: Colors.grey[600]),
+ ),
+ ],
+ ),
+ )
+ : RefreshIndicator(
+ onRefresh: () => connector.getContacts(),
+ child: ListView.builder(
+ itemCount: filteredGroups.length + filteredAndSorted.length,
+ itemBuilder: (context, index) {
+ if (index < filteredGroups.length) {
+ final group = filteredGroups[index];
+ return _buildGroupTile(context, group, contacts);
+ }
+ final contact = filteredAndSorted[index - filteredGroups.length];
+ final unreadCount = connector.getUnreadCountForContact(contact);
+ return _ContactTile(
+ contact: contact,
+ lastSeen: _resolveLastSeen(contact),
+ unreadCount: unreadCount,
+ onTap: () => _openChat(context, contact),
+ onLongPress: () => _showContactOptions(context, connector, contact),
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
List _filterAndSortGroups(List groups, List contacts) {
final query = _searchQuery.trim().toLowerCase();
- final contactNames = {};
+ final contactsByKey = {};
for (final contact in contacts) {
- contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
+ contactsByKey[contact.publicKeyHex] = contact;
}
final filtered = groups.where((group) {
if (query.isEmpty) return true;
if (group.name.toLowerCase().contains(query)) return true;
for (final key in group.memberKeys) {
- final name = contactNames[key];
- if (name != null && name.contains(query)) return true;
+ final contact = contactsByKey[key];
+ if (contact != null && matchesContactQuery(contact, query)) return true;
}
return false;
}).toList();
@@ -340,7 +395,7 @@ class _ContactsScreenState extends State {
List _filterAndSortContacts(List contacts, MeshCoreConnector connector) {
var filtered = contacts.where((contact) {
if (_searchQuery.isEmpty) return true;
- return contact.name.toLowerCase().contains(_searchQuery);
+ return matchesContactQuery(contact, _searchQuery);
}).toList();
if (_showUnreadOnly) {
@@ -349,9 +404,10 @@ class _ContactsScreenState extends State {
}).toList();
}
- switch (_sortOption) {
+ final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
+ switch (sortOption) {
case ContactSortOption.lastSeen:
- filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
+ filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
break;
case ContactSortOption.recentMessages:
filtered.sort((a, b) {
@@ -377,6 +433,13 @@ class _ContactsScreenState extends State {
return filtered;
}
+ DateTime _resolveLastSeen(Contact contact) {
+ if (contact.type != advTypeChat) return contact.lastSeen;
+ return contact.lastMessageAt.isAfter(contact.lastSeen)
+ ? contact.lastMessageAt
+ : contact.lastSeen;
+ }
+
Widget _buildGroupTile(BuildContext context, ContactGroup group, List contacts) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts);
@@ -432,6 +495,28 @@ class _ContactsScreenState extends State {
}
}
+ void _handleQuickSwitch(int index, BuildContext context) {
+ if (index == 0) return;
+ switch (index) {
+ case 1:
+ Navigator.pushReplacement(
+ context,
+ buildQuickSwitchRoute(
+ const ChannelsScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ case 2:
+ Navigator.pushReplacement(
+ context,
+ buildQuickSwitchRoute(
+ const MapScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ }
+ }
+
void _showRepeaterLogin(BuildContext context, Contact repeater) {
showDialog(
context: context,
@@ -542,7 +627,7 @@ class _ContactsScreenState extends State {
final filteredContacts = filterQuery.isEmpty
? sortedContacts
: sortedContacts
- .where((contact) => contact.name.toLowerCase().contains(filterQuery))
+ .where((contact) => matchesContactQuery(contact, filterQuery))
.toList();
return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'),
@@ -728,12 +813,14 @@ class _ContactsScreenState extends State {
class _ContactTile extends StatelessWidget {
final Contact contact;
+ final DateTime lastSeen;
final int unreadCount;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTile({
required this.contact,
+ required this.lastSeen,
required this.unreadCount,
required this.onTap,
required this.onLongPress,
@@ -757,7 +844,7 @@ class _ContactTile extends StatelessWidget {
const SizedBox(height: 4),
],
Text(
- _formatLastSeen(contact.lastSeen),
+ _formatLastSeen(lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
@@ -814,10 +901,13 @@ class _ContactTile extends StatelessWidget {
final now = DateTime.now();
final diff = now.difference(lastSeen);
- if (diff.inMinutes < 1) return 'Just now';
- if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
- if (diff.inHours < 24) return '${diff.inHours}h ago';
- if (diff.inDays < 7) return '${diff.inDays}d ago';
- return '${lastSeen.month}/${lastSeen.day}';
+ if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
+ if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
+ if (diff.inHours < 24) {
+ final hours = diff.inHours;
+ return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
+ }
+ final days = diff.inDays;
+ return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
}
}
diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart
index 26d694e..d99aa57 100644
--- a/lib/screens/device_screen.dart
+++ b/lib/screens/device_screen.dart
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
+import '../utils/route_transitions.dart';
+import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart';
import 'contacts_screen.dart';
import 'map_screen.dart';
@@ -17,6 +19,7 @@ class DeviceScreen extends StatefulWidget {
class _DeviceScreenState extends State {
bool _showBatteryVoltage = false;
+ int _quickIndex = 0;
@override
Widget build(BuildContext context) {
@@ -31,14 +34,26 @@ class _DeviceScreenState extends State {
});
}
+ final theme = Theme.of(context);
+
return PopScope(
canPop: false,
child: Scaffold(
appBar: AppBar(
- title: Text(connector.deviceDisplayName),
- centerTitle: true,
- automaticallyImplyLeading: false,
+ titleSpacing: 16,
+ centerTitle: false,
+ title: _buildAppBarTitle(connector, theme),
actions: [
+ IconButton(
+ icon: const Icon(Icons.tune),
+ tooltip: 'Settings',
+ onPressed: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const SettingsScreen(),
+ ),
+ ),
+ ),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
@@ -46,20 +61,15 @@ class _DeviceScreenState extends State {
),
],
),
- body: Padding(
- padding: const EdgeInsets.all(16.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
+ body: SafeArea(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
- // Connection status card
- _buildStatusCard(connector, context),
-
- const SizedBox(height: 24),
-
- // Navigation grid
- Expanded(
- child: _buildNavigationGrid(context),
- ),
+ _buildConnectionCard(connector, context),
+ const SizedBox(height: 16),
+ _buildSectionLabel(theme, 'Quick switch'),
+ const SizedBox(height: 12),
+ _buildQuickSwitchBar(context),
],
),
),
@@ -69,54 +79,114 @@ class _DeviceScreenState extends State {
);
}
- Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
+ Widget _buildAppBarTitle(MeshCoreConnector connector, ThemeData theme) {
+ final colorScheme = theme.colorScheme;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'MeshCore',
+ style: theme.textTheme.labelSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.8,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ Text(
+ connector.deviceDisplayName,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildSectionLabel(ThemeData theme, String text) {
+ return Text(
+ text,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.6,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ );
+ }
+
+ Widget _buildConnectionCard(
+ MeshCoreConnector connector,
+ BuildContext context,
+ ) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
return Card(
+ elevation: 0,
+ color: colorScheme.surfaceVariant,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(24),
+ ),
child: Padding(
- padding: const EdgeInsets.all(16.0),
- child: Row(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
- const SizedBox(width: 16),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- connector.deviceDisplayName,
- style: const TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- connector.deviceIdLabel,
- style: TextStyle(
- fontSize: 12,
- color: Colors.grey[600],
- ),
- ),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
- decoration: BoxDecoration(
- color: Colors.green.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(16),
- ),
- child: const Text(
- 'Connected',
- style: TextStyle(
- color: Colors.green,
- fontWeight: FontWeight.w500,
- ),
+ CircleAvatar(
+ radius: 24,
+ backgroundColor: colorScheme.primaryContainer,
+ child: Icon(
+ Icons.wifi_tethering_rounded,
+ color: colorScheme.onPrimaryContainer,
),
),
- const SizedBox(height: 8),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ connector.deviceDisplayName,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ connector.deviceIdLabel,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Chip(
+ avatar: Icon(
+ Icons.check_circle,
+ size: 18,
+ color: colorScheme.onSecondaryContainer,
+ ),
+ label: const Text('Connected'),
+ backgroundColor: colorScheme.secondaryContainer,
+ labelStyle: theme.textTheme.labelMedium?.copyWith(
+ color: colorScheme.onSecondaryContainer,
+ fontWeight: FontWeight.w600,
+ ),
+ visualDensity: VisualDensity.compact,
+ ),
_buildBatteryIndicator(connector, context),
],
),
@@ -126,7 +196,22 @@ class _DeviceScreenState extends State {
);
}
- Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
+ Widget _buildQuickSwitchBar(BuildContext context) {
+ return QuickSwitchBar(
+ selectedIndex: _quickIndex,
+ onDestinationSelected: (index) {
+ _openQuickDestination(index, context);
+ },
+ );
+ }
+
+
+ Widget _buildBatteryIndicator(
+ MeshCoreConnector connector,
+ BuildContext context,
+ ) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
final percentLabel = percent != null ? '$percent%' : '--%';
@@ -136,31 +221,24 @@ class _DeviceScreenState extends State {
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
final icon = _batteryIcon(percent);
- return InkWell(
- borderRadius: BorderRadius.circular(16),
- onTap: () {
+ return ActionChip(
+ avatar: Icon(
+ icon,
+ size: 16,
+ color: colorScheme.onSecondaryContainer,
+ ),
+ label: Text(displayLabel),
+ labelStyle: theme.textTheme.labelMedium?.copyWith(
+ color: colorScheme.onSecondaryContainer,
+ fontWeight: FontWeight.w600,
+ ),
+ backgroundColor: colorScheme.secondaryContainer,
+ visualDensity: VisualDensity.compact,
+ onPressed: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, size: 18, color: Colors.grey[700]),
- const SizedBox(width: 4),
- Text(
- displayLabel,
- style: TextStyle(
- fontSize: 12,
- color: Colors.grey[700],
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- ),
- ),
);
}
@@ -170,89 +248,44 @@ class _DeviceScreenState extends State {
return Icons.battery_full;
}
- Widget _buildNavigationGrid(BuildContext context) {
- final items = [
- _NavItem(
- icon: Icons.people_outline,
- label: 'Contacts',
- color: Colors.blue,
- onTap: () => Navigator.push(
+ void _openQuickDestination(int index, BuildContext context) {
+ if (_quickIndex != index) {
+ setState(() {
+ _quickIndex = index;
+ });
+ }
+ switch (index) {
+ case 0:
+ Navigator.pushReplacement(
context,
- MaterialPageRoute(builder: (context) => const ContactsScreen()),
- ),
- ),
- _NavItem(
- icon: Icons.tag,
- label: 'Channels',
- color: Colors.green,
- onTap: () => Navigator.push(
+ buildQuickSwitchRoute(
+ const ContactsScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ case 1:
+ Navigator.pushReplacement(
context,
- MaterialPageRoute(builder: (context) => const ChannelsScreen()),
- ),
- ),
- _NavItem(
- icon: Icons.map_outlined,
- label: 'Map',
- color: Colors.orange,
- onTap: () => Navigator.push(
+ buildQuickSwitchRoute(
+ const ChannelsScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ case 2:
+ Navigator.pushReplacement(
context,
- MaterialPageRoute(builder: (context) => const MapScreen()),
- ),
- ),
- _NavItem(
- icon: Icons.settings_outlined,
- label: 'Settings',
- color: Colors.grey,
- onTap: () => Navigator.push(
- context,
- MaterialPageRoute(builder: (context) => const SettingsScreen()),
- ),
- ),
- ];
-
- return GridView.builder(
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 2,
- crossAxisSpacing: 16,
- mainAxisSpacing: 16,
- childAspectRatio: 1.2,
- ),
- itemCount: items.length,
- itemBuilder: (context, index) {
- final item = items[index];
- return _buildNavCard(item);
- },
- );
+ buildQuickSwitchRoute(
+ const MapScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ }
}
- Widget _buildNavCard(_NavItem item) {
- return Card(
- child: InkWell(
- onTap: item.onTap,
- borderRadius: BorderRadius.circular(12),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- item.icon,
- size: 48,
- color: item.color,
- ),
- const SizedBox(height: 12),
- Text(
- item.label,
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w500,
- ),
- ),
- ],
- ),
- ),
- );
- }
-
- Future _disconnect(BuildContext context, MeshCoreConnector connector) async {
+ Future _disconnect(
+ BuildContext context,
+ MeshCoreConnector connector,
+ ) async {
final confirmed = await showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -276,17 +309,3 @@ class _DeviceScreenState extends State {
}
}
}
-
-class _NavItem {
- final IconData icon;
- final String label;
- final Color color;
- final VoidCallback onTap;
-
- _NavItem({
- required this.icon,
- required this.label,
- required this.color,
- required this.onTap,
- });
-}
diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart
new file mode 100644
index 0000000..dafd69c
--- /dev/null
+++ b/lib/screens/map_cache_screen.dart
@@ -0,0 +1,390 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:provider/provider.dart';
+
+import '../services/app_settings_service.dart';
+import '../services/map_tile_cache_service.dart';
+
+class MapCacheScreen extends StatefulWidget {
+ const MapCacheScreen({super.key});
+
+ @override
+ State createState() => _MapCacheScreenState();
+}
+
+class _MapCacheScreenState extends State {
+ final MapController _mapController = MapController();
+
+ LatLngBounds? _selectedBounds;
+ int _minZoom = MapTileCacheService.defaultMinZoom;
+ int _maxZoom = MapTileCacheService.defaultMaxZoom;
+ int _estimatedTiles = 0;
+ bool _isDownloading = false;
+ int _completedTiles = 0;
+ int _failedTiles = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ _loadSettings();
+ });
+ }
+
+ @override
+ void dispose() {
+ _mapController.dispose();
+ super.dispose();
+ }
+
+ void _loadSettings() {
+ final settings = context.read().settings;
+ final bounds = MapTileCacheService.boundsFromJson(settings.mapCacheBounds);
+ final minZoom = settings.mapCacheMinZoom.clamp(3, 18);
+ final maxZoom = settings.mapCacheMaxZoom.clamp(3, 18);
+ final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
+ final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
+ setState(() {
+ _minZoom = safeMin;
+ _maxZoom = safeMax;
+ _selectedBounds = bounds;
+ });
+ _updateEstimate();
+ if (bounds != null) {
+ _mapController.fitCamera(
+ CameraFit.bounds(
+ bounds: bounds,
+ padding: const EdgeInsets.all(48),
+ ),
+ );
+ }
+ }
+
+ void _updateEstimate() {
+ if (_selectedBounds == null) {
+ setState(() {
+ _estimatedTiles = 0;
+ });
+ return;
+ }
+ final cacheService = context.read();
+ final count =
+ cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
+ setState(() {
+ _estimatedTiles = count;
+ });
+ }
+
+ Future _setBoundsFromView() async {
+ final bounds = _mapController.camera.visibleBounds;
+ await _saveBounds(bounds);
+ }
+
+ Future _saveBounds(LatLngBounds bounds) async {
+ setState(() {
+ _selectedBounds = bounds;
+ });
+ final settings = context.read();
+ await settings.setMapCacheBounds(MapTileCacheService.boundsToJson(bounds));
+ _updateEstimate();
+ }
+
+ Future _clearBounds() async {
+ setState(() {
+ _selectedBounds = null;
+ _estimatedTiles = 0;
+ });
+ final settings = context.read();
+ await settings.setMapCacheBounds(null);
+ }
+
+ Future _saveZoomRange() async {
+ final settings = context.read();
+ await settings.setMapCacheZoomRange(_minZoom, _maxZoom);
+ _updateEstimate();
+ }
+
+ Future _startDownload() async {
+ final bounds = _selectedBounds;
+ if (bounds == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Select an area to cache first')),
+ );
+ return;
+ }
+
+ if (_estimatedTiles == 0) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('No tiles to download for this area')),
+ );
+ return;
+ }
+
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: const Text('Download tiles'),
+ content: Text(
+ 'Download $_estimatedTiles tiles for offline use?',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Download'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true) return;
+
+ setState(() {
+ _isDownloading = true;
+ _completedTiles = 0;
+ _failedTiles = 0;
+ });
+
+ final cacheService = context.read();
+ final result = await cacheService.downloadRegion(
+ bounds: bounds,
+ minZoom: _minZoom,
+ maxZoom: _maxZoom,
+ onProgress: (progress) {
+ if (!mounted) return;
+ setState(() {
+ _completedTiles = progress.completed;
+ _failedTiles = progress.failed;
+ });
+ },
+ );
+
+ if (!mounted) return;
+
+ setState(() {
+ _isDownloading = false;
+ _completedTiles = result.downloaded + result.failed;
+ _failedTiles = result.failed;
+ });
+
+ final message = result.failed > 0
+ ? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
+ : 'Cached ${result.downloaded} tiles';
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(message)),
+ );
+ }
+
+ Future _clearCache() async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: const Text('Clear offline cache'),
+ content: const Text('Remove all cached map tiles?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Clear'),
+ ),
+ ],
+ ),
+ );
+ if (confirmed != true) return;
+
+ final cacheService = context.read();
+ await cacheService.clearCache();
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Offline cache cleared')),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final tileCache = context.read();
+ final selectedBounds = _selectedBounds;
+ final progressValue = _estimatedTiles == 0
+ ? 0.0
+ : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Offline Map Cache'),
+ centerTitle: true,
+ ),
+ body: Column(
+ children: [
+ Expanded(
+ child: Stack(
+ children: [
+ FlutterMap(
+ mapController: _mapController,
+ options: const MapOptions(
+ initialCenter: LatLng(0, 0),
+ initialZoom: 2.0,
+ minZoom: 2.0,
+ maxZoom: 18.0,
+ ),
+ children: [
+ TileLayer(
+ urlTemplate: kMapTileUrlTemplate,
+ tileProvider: tileCache.tileProvider,
+ userAgentPackageName:
+ MapTileCacheService.userAgentPackageName,
+ maxZoom: 19,
+ ),
+ if (selectedBounds != null)
+ PolygonLayer(
+ polygons: [
+ Polygon(
+ points: _boundsToPolygon(selectedBounds),
+ borderStrokeWidth: 2,
+ color: Colors.blue.withValues(alpha: 0.2),
+ borderColor: Colors.blue,
+ ),
+ ],
+ ),
+ ],
+ ),
+ Positioned(
+ top: 12,
+ right: 12,
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Text(
+ selectedBounds == null
+ ? 'No area selected'
+ : _formatBounds(selectedBounds),
+ style: const TextStyle(fontSize: 12),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ SafeArea(
+ top: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ 'Cache Area',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Expanded(
+ child: ElevatedButton.icon(
+ icon: const Icon(Icons.crop_free),
+ label: const Text('Use Current View'),
+ onPressed: _isDownloading ? null : _setBoundsFromView,
+ ),
+ ),
+ const SizedBox(width: 12),
+ TextButton(
+ onPressed:
+ _isDownloading || selectedBounds == null ? null : _clearBounds,
+ child: const Text('Clear'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ const Text(
+ 'Zoom Range',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
+ ),
+ RangeSlider(
+ values:
+ RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
+ min: 3,
+ max: 18,
+ divisions: 15,
+ labels: RangeLabels('$_minZoom', '$_maxZoom'),
+ onChanged: _isDownloading
+ ? null
+ : (values) {
+ setState(() {
+ _minZoom = values.start.round();
+ _maxZoom = values.end.round();
+ });
+ },
+ onChangeEnd: _isDownloading
+ ? null
+ : (_) {
+ _saveZoomRange();
+ },
+ ),
+ Text('Estimated tiles: $_estimatedTiles'),
+ if (_isDownloading) ...[
+ const SizedBox(height: 8),
+ LinearProgressIndicator(value: progressValue),
+ const SizedBox(height: 4),
+ Text('Downloaded $_completedTiles / $_estimatedTiles'),
+ ],
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: ElevatedButton.icon(
+ icon: const Icon(Icons.download),
+ label: const Text('Download Tiles'),
+ onPressed: _isDownloading || selectedBounds == null
+ ? null
+ : _startDownload,
+ ),
+ ),
+ const SizedBox(width: 12),
+ OutlinedButton(
+ onPressed: _isDownloading ? null : _clearCache,
+ child: const Text('Clear Cache'),
+ ),
+ ],
+ ),
+ if (_failedTiles > 0 && !_isDownloading)
+ Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: Text(
+ 'Failed downloads: $_failedTiles',
+ style: TextStyle(color: Colors.orange[700]),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ List _boundsToPolygon(LatLngBounds bounds) {
+ return [
+ bounds.northWest,
+ bounds.northEast,
+ bounds.southEast,
+ bounds.southWest,
+ ];
+ }
+
+ String _formatBounds(LatLngBounds bounds) {
+ return 'N ${bounds.north.toStringAsFixed(4)}, '
+ 'S ${bounds.south.toStringAsFixed(4)}, '
+ 'E ${bounds.east.toStringAsFixed(4)}, '
+ 'W ${bounds.west.toStringAsFixed(4)}';
+ }
+}
diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart
index de1a200..f3c497d 100644
--- a/lib/screens/map_screen.dart
+++ b/lib/screens/map_screen.dart
@@ -9,18 +9,27 @@ import '../models/channel.dart';
import '../models/contact.dart';
import '../services/app_settings_service.dart';
import '../services/map_marker_service.dart';
+import '../services/map_tile_cache_service.dart';
+import '../utils/contact_search.dart';
+import '../utils/route_transitions.dart';
+import '../widgets/quick_switch_bar.dart';
+import 'channels_screen.dart';
import 'chat_screen.dart';
+import 'contacts_screen.dart';
+import 'settings_screen.dart';
class MapScreen extends StatefulWidget {
final LatLng? highlightPosition;
final String? highlightLabel;
final double highlightZoom;
+ final bool hideBackButton;
const MapScreen({
super.key,
this.highlightPosition,
this.highlightLabel,
this.highlightZoom = 15.0,
+ this.hideBackButton = false,
});
@override
@@ -60,6 +69,7 @@ class _MapScreenState extends State {
Widget build(BuildContext context) {
return Consumer2(
builder: (context, connector, settingsService, child) {
+ final tileCache = context.read();
final settings = settingsService.settings;
final contacts = connector.contacts;
final highlightPosition = widget.highlightPosition;
@@ -124,6 +134,22 @@ class _MapScreenState extends State {
appBar: AppBar(
title: const Text('Node Map'),
centerTitle: true,
+ automaticallyImplyLeading: !widget.hideBackButton,
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.tune),
+ tooltip: 'Settings',
+ onPressed: () => Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const SettingsScreen()),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.bluetooth_disabled),
+ tooltip: 'Disconnect',
+ onPressed: () => _disconnect(context, connector),
+ ),
+ ],
),
body: !hasMapContent
? _buildEmptyState()
@@ -173,8 +199,10 @@ class _MapScreenState extends State {
),
children: [
TileLayer(
- urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
- userAgentPackageName: 'com.meshcore.open',
+ urlTemplate: kMapTileUrlTemplate,
+ tileProvider: tileCache.tileProvider,
+ userAgentPackageName:
+ MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer(
@@ -199,6 +227,13 @@ class _MapScreenState extends State {
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
],
),
+ bottomNavigationBar: SafeArea(
+ top: false,
+ child: QuickSwitchBar(
+ selectedIndex: 2,
+ onDestinationSelected: (index) => _handleQuickSwitch(index, context),
+ ),
+ ),
floatingActionButton: FloatingActionButton(
onPressed: () => _showFilterDialog(context, settingsService),
child: const Icon(Icons.filter_list),
@@ -556,6 +591,55 @@ class _MapScreenState extends State {
);
}
+ void _handleQuickSwitch(int index, BuildContext context) {
+ if (index == 2) return;
+ switch (index) {
+ case 0:
+ Navigator.pushReplacement(
+ context,
+ buildQuickSwitchRoute(
+ const ContactsScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ case 1:
+ Navigator.pushReplacement(
+ context,
+ buildQuickSwitchRoute(
+ const ChannelsScreen(hideBackButton: true),
+ ),
+ );
+ break;
+ }
+ }
+
+ Future _disconnect(
+ BuildContext context,
+ MeshCoreConnector connector,
+ ) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Disconnect'),
+ content: const Text('Are you sure you want to disconnect from this device?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Disconnect'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true) {
+ await connector.disconnect();
+ }
+ }
+
void _showMarkerInfo(_SharedMarker marker) {
showDialog(
context: context,
@@ -792,8 +876,7 @@ class _MapScreenState extends State {
),
...allContacts
.where((contact) =>
- query.isEmpty ||
- contact.name.toLowerCase().contains(query))
+ query.isEmpty || matchesContactQuery(contact, query))
.map((contact) {
return ListTile(
leading: const Icon(Icons.person),
diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart
index b558ed8..b0e31e1 100644
--- a/lib/screens/scanner_screen.dart
+++ b/lib/screens/scanner_screen.dart
@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../widgets/device_tile.dart';
-import 'device_screen.dart';
+import 'contacts_screen.dart';
/// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatelessWidget {
@@ -161,7 +161,7 @@ class ScannerScreen extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
- builder: (context) => const DeviceScreen(),
+ builder: (context) => const ContactsScreen(),
),
);
}
diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart
index ac8e64c..e79f973 100644
--- a/lib/services/app_settings_service.dart
+++ b/lib/services/app_settings_service.dart
@@ -73,6 +73,21 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowMarkers: value));
}
+ Future setMapCacheBounds(Map? value) async {
+ await updateSettings(_settings.copyWith(mapCacheBounds: value));
+ }
+
+ Future setMapCacheZoomRange(int minZoom, int maxZoom) async {
+ final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
+ final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
+ await updateSettings(
+ _settings.copyWith(
+ mapCacheMinZoom: safeMin,
+ mapCacheMaxZoom: safeMax,
+ ),
+ );
+ }
+
Future setNotificationsEnabled(bool value) async {
await updateSettings(_settings.copyWith(notificationsEnabled: value));
}
@@ -81,6 +96,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
}
+ Future setNotifyOnNewChannelMessage(bool value) async {
+ await updateSettings(_settings.copyWith(notifyOnNewChannelMessage: value));
+ }
+
Future setNotifyOnNewAdvert(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
}
diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart
new file mode 100644
index 0000000..fce77a1
--- /dev/null
+++ b/lib/services/background_service.dart
@@ -0,0 +1,82 @@
+import 'dart:isolate';
+import 'dart:io';
+
+import 'package:flutter_foreground_task/flutter_foreground_task.dart';
+
+class BackgroundService {
+ bool _initialized = false;
+
+ Future initialize() async {
+ if (!Platform.isAndroid || _initialized) return;
+ FlutterForegroundTask.init(
+ androidNotificationOptions: AndroidNotificationOptions(
+ channelId: 'meshcore_background',
+ channelName: 'MeshCore Background',
+ channelDescription: 'Keeps MeshCore running in the background.',
+ channelImportance: NotificationChannelImportance.LOW,
+ priority: NotificationPriority.LOW,
+ iconData: const NotificationIconData(
+ resType: ResourceType.mipmap,
+ resPrefix: ResourcePrefix.ic,
+ name: 'launcher',
+ ),
+ ),
+ iosNotificationOptions: const IOSNotificationOptions(
+ showNotification: false,
+ playSound: false,
+ ),
+ foregroundTaskOptions: const ForegroundTaskOptions(
+ interval: 5000,
+ autoRunOnBoot: false,
+ allowWakeLock: true,
+ allowWifiLock: false,
+ ),
+ );
+ _initialized = true;
+ }
+
+ Future start() async {
+ if (!Platform.isAndroid) return;
+ if (!_initialized) {
+ await initialize();
+ }
+ final running = await FlutterForegroundTask.isRunningService;
+ if (running) return;
+ await FlutterForegroundTask.startService(
+ notificationTitle: 'MeshCore running',
+ notificationText: 'Keeping BLE connected',
+ callback: startCallback,
+ );
+ }
+
+ Future stop() async {
+ if (!Platform.isAndroid) return;
+ final running = await FlutterForegroundTask.isRunningService;
+ if (!running) return;
+ await FlutterForegroundTask.stopService();
+ }
+}
+
+@pragma('vm:entry-point')
+void startCallback() {
+ FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler());
+}
+
+class _MeshCoreTaskHandler extends TaskHandler {
+ @override
+ void onStart(DateTime timestamp, SendPort? sendPort) {}
+
+ @override
+ void onRepeatEvent(DateTime timestamp, SendPort? sendPort) {}
+
+ @override
+ void onDestroy(DateTime timestamp, SendPort? sendPort) {}
+
+ @override
+ void onNotificationButtonPressed(String id) {}
+
+ @override
+ void onNotificationPressed() {
+ FlutterForegroundTask.launchApp('/');
+ }
+}
diff --git a/lib/services/codec2_ffi.dart b/lib/services/codec2_ffi.dart
new file mode 100644
index 0000000..4f3bce7
--- /dev/null
+++ b/lib/services/codec2_ffi.dart
@@ -0,0 +1,152 @@
+import 'dart:ffi';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:ffi/ffi.dart';
+
+const int _codec2Mode1300 = 4;
+
+class Codec2Ffi {
+ Codec2Ffi._(this._lib)
+ : _codec2Create = _lib
+ .lookupFunction<_codec2_create_c, _codec2_create_d>('codec2_create'),
+ _codec2Destroy = _lib
+ .lookupFunction<_codec2_destroy_c, _codec2_destroy_d>('codec2_destroy'),
+ _codec2Encode = _lib
+ .lookupFunction<_codec2_encode_c, _codec2_encode_d>('codec2_encode'),
+ _codec2Decode = _lib
+ .lookupFunction<_codec2_decode_c, _codec2_decode_d>('codec2_decode'),
+ _codec2SamplesPerFrame = _lib.lookupFunction<_codec2_samples_per_frame_c,
+ _codec2_samples_per_frame_d>('codec2_samples_per_frame'),
+ _codec2BytesPerFrame = _lib.lookupFunction<_codec2_bytes_per_frame_c,
+ _codec2_bytes_per_frame_d>('codec2_bytes_per_frame');
+
+ static final Codec2Ffi instance = Codec2Ffi._(_openLibrary());
+
+ final DynamicLibrary _lib;
+ final _codec2_create_d _codec2Create;
+ final _codec2_destroy_d _codec2Destroy;
+ final _codec2_encode_d _codec2Encode;
+ final _codec2_decode_d _codec2Decode;
+ final _codec2_samples_per_frame_d _codec2SamplesPerFrame;
+ final _codec2_bytes_per_frame_d _codec2BytesPerFrame;
+
+ Codec2Session createSession() {
+ final handle = _codec2Create(_codec2Mode1300);
+ if (handle == nullptr) {
+ throw StateError('codec2_create returned null');
+ }
+ return Codec2Session._(
+ handle: handle,
+ destroy: _codec2Destroy,
+ encode: _codec2Encode,
+ decode: _codec2Decode,
+ samplesPerFrame: _codec2SamplesPerFrame,
+ bytesPerFrame: _codec2BytesPerFrame,
+ );
+ }
+
+ static DynamicLibrary _openLibrary() {
+ if (Platform.isAndroid) {
+ return DynamicLibrary.open('libcodec2.so');
+ }
+ if (Platform.isIOS || Platform.isMacOS) {
+ return DynamicLibrary.process();
+ }
+ throw UnsupportedError('Codec2 is only supported on Android and iOS.');
+ }
+}
+
+class Codec2Session {
+ Codec2Session._({
+ required this.handle,
+ required this.destroy,
+ required this.encode,
+ required this.decode,
+ required this.samplesPerFrame,
+ required this.bytesPerFrame,
+ });
+
+ final Pointer handle;
+ final _codec2_destroy_d destroy;
+ final _codec2_encode_d encode;
+ final _codec2_decode_d decode;
+ final _codec2_samples_per_frame_d samplesPerFrame;
+ final _codec2_bytes_per_frame_d bytesPerFrame;
+
+ int get samplesPerFrameValue => samplesPerFrame(handle);
+ int get bytesPerFrameValue => bytesPerFrame(handle);
+
+ Uint8List encodePcmFrame(Int16List pcmFrame) {
+ final bytesOut = calloc(bytesPerFrameValue);
+ final pcmIn = calloc(samplesPerFrameValue);
+ try {
+ final sampleCount = samplesPerFrameValue;
+ final pcmBuffer = pcmIn.asTypedList(sampleCount);
+ final copyLen = pcmFrame.length < sampleCount ? pcmFrame.length : sampleCount;
+ pcmBuffer.setRange(0, copyLen, pcmFrame);
+ if (copyLen < sampleCount) {
+ for (var i = copyLen; i < sampleCount; i++) {
+ pcmBuffer[i] = 0;
+ }
+ }
+ encode(handle, bytesOut, pcmIn);
+ return Uint8List.fromList(bytesOut.asTypedList(bytesPerFrameValue));
+ } finally {
+ calloc.free(bytesOut);
+ calloc.free(pcmIn);
+ }
+ }
+
+ Int16List decodeCodecFrame(Uint8List codecFrame) {
+ final pcmOut = calloc(samplesPerFrameValue);
+ final bytesIn = calloc(bytesPerFrameValue);
+ try {
+ final codecBuffer = bytesIn.asTypedList(bytesPerFrameValue);
+ codecBuffer.setRange(0, bytesPerFrameValue, codecFrame);
+ decode(handle, pcmOut, bytesIn);
+ return Int16List.fromList(pcmOut.asTypedList(samplesPerFrameValue));
+ } finally {
+ calloc.free(bytesIn);
+ calloc.free(pcmOut);
+ }
+ }
+
+ void dispose() {
+ destroy(handle);
+ }
+}
+
+typedef _codec2_create_c = Pointer Function(Int32 mode);
+typedef _codec2_create_d = Pointer Function(int mode);
+
+typedef _codec2_destroy_c = Void Function(Pointer codec2State);
+typedef _codec2_destroy_d = void Function(Pointer codec2State);
+
+typedef _codec2_encode_c = Void Function(
+ Pointer codec2State,
+ Pointer bytes,
+ Pointer speechIn,
+);
+typedef _codec2_encode_d = void Function(
+ Pointer codec2State,
+ Pointer bytes,
+ Pointer speechIn,
+);
+
+typedef _codec2_decode_c = Void Function(
+ Pointer codec2State,
+ Pointer speechOut,
+ Pointer bytes,
+);
+typedef _codec2_decode_d = void Function(
+ Pointer codec2State,
+ Pointer speechOut,
+ Pointer bytes,
+);
+
+typedef _codec2_samples_per_frame_c = Int32 Function(Pointer codec2State);
+typedef _codec2_samples_per_frame_d = int Function(Pointer codec2State);
+
+typedef _codec2_bytes_per_frame_c = Int32 Function(Pointer codec2State);
+typedef _codec2_bytes_per_frame_d = int Function(Pointer codec2State);
diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart
new file mode 100644
index 0000000..47910f3
--- /dev/null
+++ b/lib/services/map_tile_cache_service.dart
@@ -0,0 +1,241 @@
+import 'dart:math' as math;
+
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
+
+const String kMapTileUrlTemplate =
+ 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
+
+class MapTileCacheProgress {
+ final int completed;
+ final int total;
+ final int failed;
+
+ const MapTileCacheProgress({
+ required this.completed,
+ required this.total,
+ required this.failed,
+ });
+}
+
+class MapTileCacheResult {
+ final int total;
+ final int downloaded;
+ final int failed;
+
+ const MapTileCacheResult({
+ required this.total,
+ required this.downloaded,
+ required this.failed,
+ });
+}
+
+class MapTileCacheService {
+ static const String cacheKey = 'map_tile_cache';
+ static const String userAgentPackageName = 'com.meshcore.open';
+ static const int defaultMinZoom = 10;
+ static const int defaultMaxZoom = 15;
+
+ final BaseCacheManager cacheManager;
+ late final TileProvider tileProvider;
+
+ MapTileCacheService({BaseCacheManager? cacheManager})
+ : cacheManager = cacheManager ??
+ CacheManager(
+ Config(
+ cacheKey,
+ stalePeriod: const Duration(days: 365),
+ maxNrOfCacheObjects: 200000,
+ ),
+ ) {
+ tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
+ }
+
+ Map get defaultHeaders => {
+ 'User-Agent': 'flutter_map ($userAgentPackageName)',
+ };
+
+ Future clearCache() async {
+ await cacheManager.emptyCache();
+ }
+
+ int estimateTileCount(LatLngBounds bounds, int minZoom, int maxZoom) {
+ final safeMin = math.min(minZoom, maxZoom);
+ final safeMax = math.max(minZoom, maxZoom);
+ int total = 0;
+
+ for (int zoom = safeMin; zoom <= safeMax; zoom++) {
+ final tileBounds = _tileBoundsForBounds(bounds, zoom);
+ final xCount = tileBounds.maxX - tileBounds.minX + 1;
+ final yCount = tileBounds.maxY - tileBounds.minY + 1;
+ total += xCount * yCount;
+ }
+ return total;
+ }
+
+ Future downloadRegion({
+ required LatLngBounds bounds,
+ required int minZoom,
+ required int maxZoom,
+ int concurrentDownloads = 8,
+ Map? headers,
+ void Function(MapTileCacheProgress progress)? onProgress,
+ }) async {
+ final safeMin = math.min(minZoom, maxZoom);
+ final safeMax = math.max(minZoom, maxZoom);
+ final total = estimateTileCount(bounds, safeMin, safeMax);
+ final authHeaders = headers ?? defaultHeaders;
+ final safeConcurrency = math.max(1, concurrentDownloads);
+ int completed = 0;
+ int failed = 0;
+
+ final pending = >[];
+ Future queueDownload(String url) async {
+ final future = cacheManager
+ .downloadFile(url, key: url, authHeaders: authHeaders)
+ .then((_) {
+ completed += 1;
+ }).catchError((_) {
+ completed += 1;
+ failed += 1;
+ }).whenComplete(() {
+ onProgress?.call(MapTileCacheProgress(
+ completed: completed,
+ total: total,
+ failed: failed,
+ ));
+ });
+
+ pending.add(future);
+ if (pending.length >= safeConcurrency) {
+ await Future.wait(pending);
+ pending.clear();
+ }
+ }
+
+ for (int zoom = safeMin; zoom <= safeMax; zoom++) {
+ final tileBounds = _tileBoundsForBounds(bounds, zoom);
+ for (int x = tileBounds.minX; x <= tileBounds.maxX; x++) {
+ for (int y = tileBounds.minY; y <= tileBounds.maxY; y++) {
+ final url = _buildTileUrl(x, y, zoom);
+ await queueDownload(url);
+ }
+ }
+ }
+
+ if (pending.isNotEmpty) {
+ await Future.wait(pending);
+ }
+
+ return MapTileCacheResult(
+ total: total,
+ downloaded: completed - failed,
+ failed: failed,
+ );
+ }
+
+ static Map boundsToJson(LatLngBounds bounds) {
+ return {
+ 'north': bounds.north,
+ 'south': bounds.south,
+ 'east': bounds.east,
+ 'west': bounds.west,
+ };
+ }
+
+ static LatLngBounds? boundsFromJson(Map? json) {
+ if (json == null) return null;
+ final north = (json['north'] as num?)?.toDouble();
+ final south = (json['south'] as num?)?.toDouble();
+ final east = (json['east'] as num?)?.toDouble();
+ final west = (json['west'] as num?)?.toDouble();
+ if (north == null || south == null || east == null || west == null) {
+ return null;
+ }
+ return LatLngBounds.unsafe(
+ north: north,
+ south: south,
+ east: east,
+ west: west,
+ );
+ }
+
+ _TileBounds _tileBoundsForBounds(LatLngBounds bounds, int zoom) {
+ final north = _clampLatitude(bounds.north);
+ final south = _clampLatitude(bounds.south);
+ final maxIndex = (1 << zoom) - 1;
+
+ final minX = _lonToTileX(bounds.west, zoom, maxIndex);
+ final maxX = _lonToTileX(bounds.east, zoom, maxIndex);
+ final minY = _latToTileY(north, zoom, maxIndex);
+ final maxY = _latToTileY(south, zoom, maxIndex);
+
+ return _TileBounds(
+ minX: math.min(minX, maxX),
+ maxX: math.max(minX, maxX),
+ minY: math.min(minY, maxY),
+ maxY: math.max(minY, maxY),
+ );
+ }
+
+ int _lonToTileX(double lon, int zoom, int maxIndex) {
+ final n = 1 << zoom;
+ final value = ((lon + 180.0) / 360.0 * n).floor();
+ return value.clamp(0, maxIndex) as int;
+ }
+
+ int _latToTileY(double lat, int zoom, int maxIndex) {
+ final n = 1 << zoom;
+ final rad = lat * math.pi / 180.0;
+ final value = ((1 -
+ math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
+ 2 *
+ n)
+ .floor();
+ return value.clamp(0, maxIndex) as int;
+ }
+
+ double _clampLatitude(double lat) {
+ const maxLat = 85.05112878;
+ return lat.clamp(-maxLat, maxLat) as double;
+ }
+
+ String _buildTileUrl(int x, int y, int zoom) {
+ return kMapTileUrlTemplate
+ .replaceAll('{z}', zoom.toString())
+ .replaceAll('{x}', x.toString())
+ .replaceAll('{y}', y.toString());
+ }
+}
+
+class CachedNetworkTileProvider extends TileProvider {
+ final BaseCacheManager cacheManager;
+
+ CachedNetworkTileProvider({required this.cacheManager, super.headers});
+
+ @override
+ ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
+ final url = getTileUrl(coordinates, options);
+ return CachedNetworkImageProvider(
+ url,
+ cacheManager: cacheManager,
+ headers: headers,
+ );
+ }
+}
+
+class _TileBounds {
+ final int minX;
+ final int maxX;
+ final int minY;
+ final int maxY;
+
+ const _TileBounds({
+ required this.minX,
+ required this.maxX,
+ required this.minY,
+ required this.maxY,
+ });
+}
diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart
index 0a5b29b..1d694df 100644
--- a/lib/services/message_retry_service.dart
+++ b/lib/services/message_retry_service.dart
@@ -103,6 +103,7 @@ class MessageRetryService extends ChangeNotifier {
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
+ lastMessageAt: contact.lastMessageAt,
);
}
diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart
index 2461081..cc13ccb 100644
--- a/lib/services/notification_service.dart
+++ b/lib/services/notification_service.dart
@@ -139,6 +139,55 @@ class NotificationService {
);
}
+ Future showChannelMessageNotification({
+ required String channelName,
+ required String message,
+ int? channelIndex,
+ }) async {
+ if (!_isInitialized) {
+ await initialize();
+ }
+
+ const androidDetails = AndroidNotificationDetails(
+ 'channel_messages',
+ 'Channel Messages',
+ channelDescription: 'New channel message notifications',
+ importance: Importance.high,
+ priority: Priority.high,
+ icon: '@mipmap/ic_launcher',
+ );
+
+ const iosDetails = DarwinNotificationDetails(
+ presentAlert: true,
+ presentBadge: true,
+ presentSound: true,
+ );
+
+ const notificationDetails = NotificationDetails(
+ android: androidDetails,
+ iOS: iosDetails,
+ );
+
+ final preview = _truncateMessage(message, 30);
+ final body = preview.isEmpty
+ ? 'Received new message'
+ : 'Received new message: $preview';
+
+ await _notifications.show(
+ channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
+ channelName,
+ body,
+ notificationDetails,
+ payload: 'channel:$channelIndex',
+ );
+ }
+
+ String _truncateMessage(String message, int maxLength) {
+ final trimmed = message.trim();
+ if (trimmed.length <= maxLength) return trimmed;
+ return '${trimmed.substring(0, maxLength)}...';
+ }
+
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
diff --git a/lib/services/voice_message_service.dart b/lib/services/voice_message_service.dart
new file mode 100644
index 0000000..0b7c60e
--- /dev/null
+++ b/lib/services/voice_message_service.dart
@@ -0,0 +1,220 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:path/path.dart' as path;
+import 'package:path_provider/path_provider.dart';
+
+import 'codec2_ffi.dart';
+
+class VoiceMessageService {
+ static const int sampleRate = 8000;
+ static const int channels = 1;
+ static const int bitsPerSample = 16;
+ static const int maxRecordSeconds = 5;
+ static const int chunkRawBytes = 90;
+ static const String codecName = 'codec2_1300';
+ static const String chunkPrefix = 'V1|';
+
+ static final VoiceMessageService instance = VoiceMessageService._();
+
+ VoiceMessageService._();
+
+ Future ensureVoiceDir() async {
+ final docs = await getApplicationDocumentsDirectory();
+ final dir = Directory(path.join(docs.path, 'voice'));
+ if (!await dir.exists()) {
+ await dir.create(recursive: true);
+ }
+ return dir;
+ }
+
+ String buildVoiceFileName({
+ required String senderKeyHex,
+ required int timestampSeconds,
+ bool outgoing = false,
+ }) {
+ final suffix = outgoing ? 'out' : 'in';
+ return 'voice_${senderKeyHex}_${timestampSeconds}_$suffix.wav';
+ }
+
+ List buildVoiceChunks(Uint8List codec2Bytes) {
+ if (codec2Bytes.isEmpty) return [];
+ final chunks = [];
+ for (var offset = 0; offset < codec2Bytes.length; offset += chunkRawBytes) {
+ final end = (offset + chunkRawBytes).clamp(0, codec2Bytes.length).toInt();
+ chunks.add(Uint8List.fromList(codec2Bytes.sublist(offset, end)));
+ }
+ final count = chunks.length;
+ return List.generate(count, (index) {
+ final encoded = _base64UrlEncodeNoPad(chunks[index]);
+ return '$chunkPrefix$index/$count|$encoded';
+ });
+ }
+
+ VoiceChunk? tryParseChunk(String text) {
+ final trimmed = text.trim();
+ if (!trimmed.startsWith(chunkPrefix)) return null;
+ final match = RegExp(r'^V1\|(\d+)/(\d+)\|([A-Za-z0-9_-]+)$').firstMatch(trimmed);
+ if (match == null) return null;
+ final idx = int.tryParse(match.group(1) ?? '');
+ final count = int.tryParse(match.group(2) ?? '');
+ final payload = match.group(3);
+ if (idx == null || count == null || payload == null) return null;
+ if (idx < 0 || count <= 0 || idx >= count) return null;
+ try {
+ final bytes = _base64UrlDecode(payload);
+ return VoiceChunk(index: idx, count: count, bytes: bytes);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Uint8List encodePcmToCodec2(Uint8List pcmBytes) {
+ final session = Codec2Ffi.instance.createSession();
+ try {
+ final samplesPerFrame = session.samplesPerFrameValue;
+ final pcmSamples = _toInt16(pcmBytes);
+ final frameCount = (pcmSamples.length + samplesPerFrame - 1) ~/ samplesPerFrame;
+ final builder = BytesBuilder(copy: false);
+
+ for (var frameIndex = 0; frameIndex < frameCount; frameIndex++) {
+ final start = frameIndex * samplesPerFrame;
+ final end = (start + samplesPerFrame).clamp(0, pcmSamples.length).toInt();
+ final frame = Int16List(samplesPerFrame);
+ final copyLen = end - start;
+ if (copyLen > 0) {
+ frame.setRange(0, copyLen, pcmSamples.sublist(start, end));
+ }
+ final encoded = session.encodePcmFrame(frame);
+ builder.add(encoded);
+ }
+
+ return builder.takeBytes();
+ } finally {
+ session.dispose();
+ }
+ }
+
+ Uint8List decodeCodec2ToPcm(Uint8List codec2Bytes) {
+ final session = Codec2Ffi.instance.createSession();
+ try {
+ final bytesPerFrame = session.bytesPerFrameValue;
+ if (bytesPerFrame <= 0) return Uint8List(0);
+ final frameCount = codec2Bytes.length ~/ bytesPerFrame;
+ final builder = BytesBuilder(copy: false);
+
+ for (var frameIndex = 0; frameIndex < frameCount; frameIndex++) {
+ final start = frameIndex * bytesPerFrame;
+ final frameBytes = codec2Bytes.sublist(start, start + bytesPerFrame);
+ final decoded = session.decodeCodecFrame(frameBytes);
+ builder.add(Uint8List.view(
+ decoded.buffer,
+ decoded.offsetInBytes,
+ decoded.lengthInBytes,
+ ));
+ }
+
+ return builder.takeBytes();
+ } finally {
+ session.dispose();
+ }
+ }
+
+ int durationMsForCodec2Bytes(Uint8List codec2Bytes) {
+ final session = Codec2Ffi.instance.createSession();
+ try {
+ final bytesPerFrame = session.bytesPerFrameValue;
+ final samplesPerFrame = session.samplesPerFrameValue;
+ if (bytesPerFrame <= 0 || samplesPerFrame <= 0) return 0;
+ final frameCount = codec2Bytes.length ~/ bytesPerFrame;
+ final frameDurationMs = (samplesPerFrame * 1000 / sampleRate).round();
+ return frameCount * frameDurationMs;
+ } finally {
+ session.dispose();
+ }
+ }
+
+ Future writeWavFile({
+ required Uint8List pcmBytes,
+ required String fileName,
+ }) async {
+ final dir = await ensureVoiceDir();
+ final filePath = path.join(dir.path, fileName);
+ final wavHeader = _buildWavHeader(
+ pcmDataSize: pcmBytes.length,
+ sampleRate: sampleRate,
+ channels: channels,
+ bitsPerSample: bitsPerSample,
+ );
+ final file = File(filePath);
+ final builder = BytesBuilder(copy: false);
+ builder.add(wavHeader);
+ builder.add(pcmBytes);
+ await file.writeAsBytes(builder.takeBytes(), flush: true);
+ return filePath;
+ }
+
+ Uint8List _buildWavHeader({
+ required int pcmDataSize,
+ required int sampleRate,
+ required int channels,
+ required int bitsPerSample,
+ }) {
+ final byteRate = sampleRate * channels * (bitsPerSample ~/ 8);
+ final blockAlign = channels * (bitsPerSample ~/ 8);
+ final buffer = BytesBuilder(copy: false);
+ buffer.add(ascii.encode('RIFF'));
+ buffer.add(_le32(36 + pcmDataSize));
+ buffer.add(ascii.encode('WAVE'));
+ buffer.add(ascii.encode('fmt '));
+ buffer.add(_le32(16));
+ buffer.add(_le16(1));
+ buffer.add(_le16(channels));
+ buffer.add(_le32(sampleRate));
+ buffer.add(_le32(byteRate));
+ buffer.add(_le16(blockAlign));
+ buffer.add(_le16(bitsPerSample));
+ buffer.add(ascii.encode('data'));
+ buffer.add(_le32(pcmDataSize));
+ return buffer.takeBytes();
+ }
+
+ Uint8List _le16(int value) {
+ final data = ByteData(2)..setUint16(0, value, Endian.little);
+ return data.buffer.asUint8List();
+ }
+
+ Uint8List _le32(int value) {
+ final data = ByteData(4)..setUint32(0, value, Endian.little);
+ return data.buffer.asUint8List();
+ }
+
+ Int16List _toInt16(Uint8List bytes) {
+ final evenLength = bytes.lengthInBytes - (bytes.lengthInBytes % 2);
+ if (evenLength <= 0) return Int16List(0);
+ return Int16List.view(bytes.buffer, bytes.offsetInBytes, evenLength ~/ 2);
+ }
+
+ String _base64UrlEncodeNoPad(Uint8List bytes) {
+ return base64Url.encode(bytes).replaceAll('=', '');
+ }
+
+ Uint8List _base64UrlDecode(String encoded) {
+ final paddedLength = (encoded.length + 3) ~/ 4 * 4;
+ final padded = encoded.padRight(paddedLength, '=');
+ return base64Url.decode(padded);
+ }
+}
+
+class VoiceChunk {
+ final int index;
+ final int count;
+ final Uint8List bytes;
+
+ VoiceChunk({
+ required this.index,
+ required this.count,
+ required this.bytes,
+ });
+}
diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart
index f40377e..0d08ed7 100644
--- a/lib/storage/channel_message_store.dart
+++ b/lib/storage/channel_message_store.dart
@@ -65,6 +65,7 @@ class ChannelMessageStore {
'repeatCount': msg.repeatCount,
'pathLength': msg.pathLength,
'pathBytes': base64Encode(msg.pathBytes),
+ 'pathVariants': msg.pathVariants.map(base64Encode).toList(),
'repeats': msg.repeats.map(_repeatToJson).toList(),
};
}
@@ -87,6 +88,9 @@ class ChannelMessageStore {
pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
+ pathVariants: (json['pathVariants'] as List?)
+ ?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
+ .toList(),
repeats: (json['repeats'] as List?)
?.map((entry) => _repeatFromJson(entry as Map))
.toList() ??
diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart
index 18ce056..31078ab 100644
--- a/lib/storage/contact_store.dart
+++ b/lib/storage/contact_store.dart
@@ -37,10 +37,13 @@ class ContactStore {
'latitude': contact.latitude,
'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
+ 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
};
}
Contact _fromJson(Map json) {
+ final lastSeenMs = json['lastSeen'] as int? ?? 0;
+ final lastMessageMs = json['lastMessageAt'] as int?;
return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown',
@@ -51,7 +54,8 @@ class ContactStore {
: Uint8List(0),
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
- lastSeen: DateTime.fromMillisecondsSinceEpoch(json['lastSeen'] as int? ?? 0),
+ lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
+ lastMessageAt: DateTime.fromMillisecondsSinceEpoch(lastMessageMs ?? lastSeenMs),
);
}
}
diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart
index ecb8299..bedc83e 100644
--- a/lib/storage/message_store.dart
+++ b/lib/storage/message_store.dart
@@ -41,6 +41,10 @@ class MessageStore {
'timestamp': msg.timestamp.millisecondsSinceEpoch,
'isOutgoing': msg.isOutgoing,
'isCli': msg.isCli,
+ 'isVoice': msg.isVoice,
+ 'voicePath': msg.voicePath,
+ 'voiceDurationMs': msg.voiceDurationMs,
+ 'voiceCodec': msg.voiceCodec,
'status': msg.status.index,
'messageId': msg.messageId,
'retryCount': msg.retryCount,
@@ -65,6 +69,10 @@ class MessageStore {
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
isOutgoing: json['isOutgoing'] as bool,
isCli: isCli,
+ isVoice: json['isVoice'] as bool? ?? false,
+ voicePath: json['voicePath'] as String?,
+ voiceDurationMs: json['voiceDurationMs'] as int?,
+ voiceCodec: json['voiceCodec'] as String?,
status: MessageStatus.values[json['status'] as int],
messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0,
diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart
new file mode 100644
index 0000000..31def4e
--- /dev/null
+++ b/lib/utils/contact_search.dart
@@ -0,0 +1,26 @@
+import '../models/contact.dart';
+
+bool matchesContactQuery(Contact contact, String query) {
+ final normalizedQuery = query.trim().toLowerCase();
+ if (normalizedQuery.isEmpty) return true;
+
+ if (contact.name.toLowerCase().contains(normalizedQuery)) {
+ return true;
+ }
+
+ final hexPrefix = _extractHexPrefix(normalizedQuery);
+ if (hexPrefix == null) return false;
+
+ return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
+}
+
+String? _extractHexPrefix(String query) {
+ var cleaned = query;
+ if (cleaned.startsWith('0x')) {
+ cleaned = cleaned.substring(2);
+ }
+ cleaned = cleaned.replaceAll(' ', '');
+ if (cleaned.length < 2) return null;
+ if (!RegExp(r'^[0-9a-f]+$').hasMatch(cleaned)) return null;
+ return cleaned;
+}
diff --git a/lib/utils/route_transitions.dart b/lib/utils/route_transitions.dart
new file mode 100644
index 0000000..7de5a9c
--- /dev/null
+++ b/lib/utils/route_transitions.dart
@@ -0,0 +1,26 @@
+import 'package:flutter/material.dart';
+
+Route buildQuickSwitchRoute(Widget page) {
+ return PageRouteBuilder(
+ pageBuilder: (context, animation, secondaryAnimation) => page,
+ transitionDuration: const Duration(milliseconds: 220),
+ reverseTransitionDuration: const Duration(milliseconds: 200),
+ transitionsBuilder: (context, animation, secondaryAnimation, child) {
+ final curved = CurvedAnimation(
+ parent: animation,
+ curve: Curves.easeOutCubic,
+ reverseCurve: Curves.easeInCubic,
+ );
+ return FadeTransition(
+ opacity: curved,
+ child: SlideTransition(
+ position: Tween(
+ begin: const Offset(0.02, 0),
+ end: Offset.zero,
+ ).animate(curved),
+ child: child,
+ ),
+ );
+ },
+ );
+}
diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart
new file mode 100644
index 0000000..d612f90
--- /dev/null
+++ b/lib/widgets/quick_switch_bar.dart
@@ -0,0 +1,83 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+
+class QuickSwitchBar extends StatelessWidget {
+ final int selectedIndex;
+ final ValueChanged onDestinationSelected;
+
+ const QuickSwitchBar({
+ super.key,
+ required this.selectedIndex,
+ required this.onDestinationSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final labelStyle = theme.textTheme.labelMedium ?? const TextStyle();
+
+ return SizedBox(
+ width: double.infinity,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ border: Border.all(
+ color: colorScheme.outlineVariant.withValues(alpha: 0.4),
+ ),
+ ),
+ child: NavigationBarTheme(
+ data: NavigationBarThemeData(
+ backgroundColor: Colors.transparent,
+ surfaceTintColor: Colors.transparent,
+ shadowColor: Colors.transparent,
+ indicatorColor: colorScheme.primaryContainer,
+ labelTextStyle: MaterialStateProperty.resolveWith((states) {
+ final isSelected = states.contains(MaterialState.selected);
+ return labelStyle.copyWith(
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
+ color: isSelected
+ ? colorScheme.onPrimaryContainer
+ : colorScheme.onSurfaceVariant,
+ );
+ }),
+ iconTheme: MaterialStateProperty.resolveWith((states) {
+ final isSelected = states.contains(MaterialState.selected);
+ return IconThemeData(
+ color: isSelected
+ ? colorScheme.onPrimaryContainer
+ : colorScheme.onSurfaceVariant,
+ );
+ }),
+ ),
+ child: NavigationBar(
+ height: 60,
+ selectedIndex: selectedIndex,
+ onDestinationSelected: onDestinationSelected,
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.people_outline),
+ label: 'Contacts',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.tag),
+ label: 'Channels',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.map_outlined),
+ label: 'Map',
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/voice_message.dart b/lib/widgets/voice_message.dart
new file mode 100644
index 0000000..a976ac0
--- /dev/null
+++ b/lib/widgets/voice_message.dart
@@ -0,0 +1,134 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:media_kit_fork/media_kit_fork.dart';
+
+import '../models/message.dart';
+
+class VoiceMessageBubble extends StatefulWidget {
+ final Message message;
+ final Color backgroundColor;
+ final Color textColor;
+ final Color metaColor;
+ final bool isOutgoing;
+
+ const VoiceMessageBubble({
+ super.key,
+ required this.message,
+ required this.backgroundColor,
+ required this.textColor,
+ required this.metaColor,
+ required this.isOutgoing,
+ });
+
+ @override
+ State createState() => _VoiceMessageBubbleState();
+}
+
+class _VoiceMessageBubbleState extends State {
+ late final Player _player;
+ StreamSubscription? _durationSubscription;
+ StreamSubscription? _completeSubscription;
+ Duration _duration = Duration.zero;
+
+ @override
+ void initState() {
+ super.initState();
+ _player = Player();
+ final voicePath = widget.message.voicePath;
+ if (voicePath != null && voicePath.isNotEmpty) {
+ _player.open(Media(Uri.file(voicePath).toString()), play: false);
+ }
+ _durationSubscription = _player.stream.duration.listen((value) {
+ if (!mounted) return;
+ if (value > Duration.zero && value != _duration) {
+ setState(() {
+ _duration = value;
+ });
+ }
+ });
+ _completeSubscription = _player.stream.completed.listen((completed) {
+ if (!completed) return;
+ _player.seek(Duration.zero);
+ _player.pause();
+ });
+ }
+
+ @override
+ void dispose() {
+ _durationSubscription?.cancel();
+ _completeSubscription?.cancel();
+ _player.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final hasAudio = widget.message.voicePath != null && widget.message.voicePath!.isNotEmpty;
+ final fallbackDuration = Duration(milliseconds: widget.message.voiceDurationMs ?? 0);
+ final displayDuration = _duration > Duration.zero ? _duration : fallbackDuration;
+
+ return StreamBuilder(
+ stream: _player.stream.playing,
+ initialData: false,
+ builder: (context, playingSnapshot) {
+ final isPlaying = playingSnapshot.data ?? false;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ IconButton(
+ icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
+ color: widget.textColor,
+ onPressed: hasAudio
+ ? () {
+ if (isPlaying) {
+ _player.pause();
+ } else {
+ _player.play();
+ }
+ }
+ : null,
+ ),
+ Expanded(
+ child: StreamBuilder(
+ stream: _player.stream.position,
+ initialData: Duration.zero,
+ builder: (context, positionSnapshot) {
+ final position = positionSnapshot.data ?? Duration.zero;
+ final progress = displayDuration.inMilliseconds > 0
+ ? position.inMilliseconds / displayDuration.inMilliseconds
+ : 0.0;
+ return LinearProgressIndicator(
+ value: progress.clamp(0.0, 1.0),
+ backgroundColor: widget.metaColor.withValues(alpha: 0.2),
+ valueColor: AlwaysStoppedAnimation(widget.textColor),
+ minHeight: 4,
+ );
+ },
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ _formatDuration(displayDuration),
+ style: TextStyle(
+ color: widget.metaColor,
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ String _formatDuration(Duration duration) {
+ final totalSeconds = duration.inSeconds;
+ final minutes = totalSeconds ~/ 60;
+ final seconds = totalSeconds % 60;
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
+ }
+}
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
index e71a16d..209cfb0 100644
--- a/linux/flutter/generated_plugin_registrant.cc
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
+#include
+#include
void fl_register_plugins(FlPluginRegistry* registry) {
+ g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
+ media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_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);
}
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index 2e1de87..b8244e7 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ media_kit_libs_linux
+ record_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 7b9bdcf..2fa4dc8 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,10 +7,18 @@ import Foundation
import flutter_blue_plus_darwin
import flutter_local_notifications
+import media_kit_libs_macos_audio
+import path_provider_foundation
+import record_macos
import shared_preferences_foundation
+import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
+ MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))
+ PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
+ RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+ SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}
diff --git a/pubspec.lock b/pubspec.lock
index 75819f3..810210c 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.7"
args:
dependency: transitive
description:
@@ -33,6 +41,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
+ cached_network_image:
+ dependency: "direct main"
+ description:
+ name: cached_network_image
+ sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.1"
+ cached_network_image_platform_interface:
+ dependency: transitive
+ description:
+ name: cached_network_image_platform_interface
+ sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.1"
+ cached_network_image_web:
+ dependency: transitive
+ description:
+ name: cached_network_image_web
+ sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.1"
characters:
dependency: transitive
description:
@@ -106,7 +138,7 @@ packages:
source: hosted
version: "1.3.3"
ffi:
- dependency: transitive
+ dependency: "direct main"
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
@@ -190,6 +222,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.7"
+ flutter_cache_manager:
+ dependency: "direct main"
+ description:
+ name: flutter_cache_manager
+ sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.1"
+ flutter_foreground_task:
+ dependency: "direct main"
+ description:
+ name: flutter_foreground_task
+ sha256: "6cf10a27f5e344cd2ecad0752d3a5f4ec32846d82fda8753b3fe2480ebb832a3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -256,6 +304,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.7.2"
intl:
dependency: transitive
description:
@@ -344,6 +400,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.1"
+ media_kit_fork:
+ dependency: "direct main"
+ description:
+ name: media_kit_fork
+ sha256: aa6e7bb6153545f64a3bcfcdeaf5d245328deedc004a17731bd0e0cf7b566981
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.3"
+ media_kit_libs_android_audio:
+ dependency: transitive
+ description:
+ name: media_kit_libs_android_audio
+ sha256: "8f8f9759e537e12d66f08bc4d5279eb1bb21a0ccc519ff3442c68a9f3b6dd68b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.8"
+ media_kit_libs_audio:
+ dependency: "direct main"
+ description:
+ name: media_kit_libs_audio
+ sha256: "81bf506c234e81e3ec536ba72f8f700a928543c14c345220210cae0411636316"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.7"
+ media_kit_libs_ios_audio:
+ dependency: transitive
+ description:
+ name: media_kit_libs_ios_audio
+ sha256: "78ccf04e27d6b4ba00a355578ccb39b772f00d48269a6ac3db076edf2d51934f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.4"
+ media_kit_libs_linux:
+ dependency: transitive
+ description:
+ name: media_kit_libs_linux
+ sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ media_kit_libs_macos_audio:
+ dependency: transitive
+ description:
+ name: media_kit_libs_macos_audio
+ sha256: "3be21844df98f286de32808592835073cdef2c1a10078bac135da790badca950"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.4"
+ media_kit_libs_windows_audio:
+ dependency: transitive
+ description:
+ name: media_kit_libs_windows_audio
+ sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.9"
meta:
dependency: transitive
description:
@@ -368,14 +480,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
- path:
+ octo_image:
dependency: transitive
+ description:
+ name: octo_image
+ sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ path:
+ dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
+ path_provider:
+ dependency: "direct main"
+ description:
+ name: path_provider
+ sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.5"
+ path_provider_android:
+ dependency: transitive
+ description:
+ name: path_provider_android
+ sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.22"
+ path_provider_foundation:
+ dependency: transitive
+ description:
+ name: path_provider_foundation
+ sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
@@ -440,6 +584,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.3"
proj4dart:
dependency: transitive
description:
@@ -456,6 +608,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
+ record:
+ dependency: "direct main"
+ description:
+ name: record
+ sha256: "6bad72fb3ea6708d724cf8b6c97c4e236cf9f43a52259b654efeb6fd9b737f1f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.2"
+ record_android:
+ dependency: transitive
+ description:
+ name: record_android
+ sha256: "9aaf3f151e61399b09bd7c31eb5f78253d2962b3f57af019ac5a2d1a3afdcf71"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.5"
+ record_ios:
+ dependency: transitive
+ description:
+ name: record_ios
+ sha256: "69fcd37c6185834e90254573599a9165db18a2cbfa266b6d1e46ffffeb06a28c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.5"
+ record_linux:
+ dependency: transitive
+ description:
+ name: record_linux
+ sha256: "235b1f1fb84e810f8149cc0c2c731d7d697f8d1c333b32cb820c449bf7bb72d8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ record_macos:
+ dependency: transitive
+ description:
+ name: record_macos
+ sha256: "842ea4b7e95f4dd237aacffc686d1b0ff4277e3e5357865f8d28cd28bc18ed95"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ record_platform_interface:
+ dependency: transitive
+ description:
+ name: record_platform_interface
+ sha256: b0065fdf1ec28f5a634d676724d388a77e43ce7646fb049949f58c69f3fcb4ed
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ record_web:
+ dependency: transitive
+ description:
+ name: record_web
+ sha256: "3feeffbc0913af3021da9810bb8702a068db6bc9da52dde1d19b6ee7cb9edb51"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ record_windows:
+ dependency: transitive
+ description:
+ name: record_windows
+ sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.7"
rxdart:
dependency: transitive
description:
@@ -464,6 +680,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
+ safe_local_storage:
+ dependency: transitive
+ description:
+ name: safe_local_storage
+ sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
shared_preferences:
dependency: "direct main"
description:
@@ -533,6 +757,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
+ sqflite:
+ dependency: transitive
+ description:
+ name: sqflite
+ sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_android:
+ dependency: transitive
+ description:
+ name: sqflite_android
+ sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2+2"
+ sqflite_common:
+ dependency: transitive
+ description:
+ name: sqflite_common
+ sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.6"
+ sqflite_darwin:
+ dependency: transitive
+ description:
+ name: sqflite_darwin
+ sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_platform_interface:
+ dependency: transitive
+ description:
+ name: sqflite_platform_interface
+ sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -557,6 +821,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ synchronized:
+ dependency: transitive
+ description:
+ name: synchronized
+ sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -597,6 +869,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
+ universal_platform:
+ dependency: transitive
+ description:
+ name: universal_platform
+ sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ uri_parser:
+ dependency: transitive
+ description:
+ name: uri_parser
+ sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
uuid:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 7868d7b..032d08d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -44,6 +44,15 @@ dependencies:
crypto: ^3.0.3
pointycastle: ^3.7.4
http: ^1.2.0
+ ffi: ^2.1.3
+ cached_network_image: ^3.4.1
+ flutter_cache_manager: ^3.4.1
+ media_kit_fork: ^0.0.2
+ media_kit_libs_audio: ^1.0.5
+ path: ^1.9.0
+ path_provider: ^2.1.2
+ record: ^6.1.2
+ flutter_foreground_task: ^6.1.2
dev_dependencies:
flutter_test:
diff --git a/third_party/codec2/.clang-format b/third_party/codec2/.clang-format
new file mode 100644
index 0000000..f2dd0de
--- /dev/null
+++ b/third_party/codec2/.clang-format
@@ -0,0 +1,168 @@
+---
+Language: Cpp
+# BasedOnStyle: Google
+AccessModifierOffset: -1
+AlignAfterOpenBracket: Align
+AlignConsecutiveMacros: false
+AlignConsecutiveAssignments: false
+AlignConsecutiveDeclarations: false
+AlignEscapedNewlines: Left
+AlignOperands: true
+AlignTrailingComments: true
+AllowAllArgumentsOnNextLine: true
+AllowAllConstructorInitializersOnNextLine: true
+AllowAllParametersOfDeclarationOnNextLine: true
+AllowShortBlocksOnASingleLine: Never
+AllowShortCaseLabelsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: All
+AllowShortLambdasOnASingleLine: All
+AllowShortIfStatementsOnASingleLine: WithoutElse
+AllowShortLoopsOnASingleLine: true
+AlwaysBreakAfterDefinitionReturnType: None
+AlwaysBreakAfterReturnType: None
+AlwaysBreakBeforeMultilineStrings: true
+AlwaysBreakTemplateDeclarations: Yes
+BinPackArguments: true
+BinPackParameters: true
+BraceWrapping:
+ AfterCaseLabel: false
+ AfterClass: false
+ AfterControlStatement: false
+ AfterEnum: false
+ AfterFunction: false
+ AfterNamespace: false
+ AfterObjCDeclaration: false
+ AfterStruct: false
+ AfterUnion: false
+ AfterExternBlock: false
+ BeforeCatch: false
+ BeforeElse: false
+ IndentBraces: false
+ SplitEmptyFunction: true
+ SplitEmptyRecord: true
+ SplitEmptyNamespace: true
+BreakBeforeBinaryOperators: None
+BreakBeforeBraces: Attach
+BreakBeforeInheritanceComma: false
+BreakInheritanceList: BeforeColon
+BreakBeforeTernaryOperators: true
+BreakConstructorInitializersBeforeComma: false
+BreakConstructorInitializers: BeforeColon
+BreakAfterJavaFieldAnnotations: false
+BreakStringLiterals: true
+ColumnLimit: 80
+CommentPragmas: '^ IWYU pragma:'
+CompactNamespaces: false
+ConstructorInitializerAllOnOneLineOrOnePerLine: true
+ConstructorInitializerIndentWidth: 4
+ContinuationIndentWidth: 4
+Cpp11BracedListStyle: true
+DeriveLineEnding: true
+DerivePointerAlignment: true
+DisableFormat: false
+ExperimentalAutoDetectBinPacking: false
+FixNamespaceComments: true
+ForEachMacros:
+ - foreach
+ - Q_FOREACH
+ - BOOST_FOREACH
+IncludeBlocks: Regroup
+IncludeCategories:
+ - Regex: '^'
+ Priority: 2
+ SortPriority: 0
+ - Regex: '^<.*\.h>'
+ Priority: 1
+ SortPriority: 0
+ - Regex: '^<.*'
+ Priority: 2
+ SortPriority: 0
+ - Regex: '.*'
+ Priority: 3
+ SortPriority: 0
+IncludeIsMainRegex: '([-_](test|unittest))?$'
+IncludeIsMainSourceRegex: ''
+IndentCaseLabels: true
+IndentGotoLabels: true
+IndentPPDirectives: None
+IndentWidth: 2
+IndentWrappedFunctionNames: false
+JavaScriptQuotes: Leave
+JavaScriptWrapImports: true
+KeepEmptyLinesAtTheStartOfBlocks: false
+MacroBlockBegin: ''
+MacroBlockEnd: ''
+MaxEmptyLinesToKeep: 1
+NamespaceIndentation: None
+ObjCBinPackProtocolList: Never
+ObjCBlockIndentWidth: 2
+ObjCSpaceAfterProperty: false
+ObjCSpaceBeforeProtocolList: true
+PenaltyBreakAssignment: 2
+PenaltyBreakBeforeFirstCallParameter: 1
+PenaltyBreakComment: 300
+PenaltyBreakFirstLessLess: 120
+PenaltyBreakString: 1000
+PenaltyBreakTemplateDeclaration: 10
+PenaltyExcessCharacter: 1000000
+PenaltyReturnTypeOnItsOwnLine: 200
+PointerAlignment: Left
+RawStringFormats:
+ - Language: Cpp
+ Delimiters:
+ - cc
+ - CC
+ - cpp
+ - Cpp
+ - CPP
+ - 'c++'
+ - 'C++'
+ CanonicalDelimiter: ''
+ BasedOnStyle: google
+ - Language: TextProto
+ Delimiters:
+ - pb
+ - PB
+ - proto
+ - PROTO
+ EnclosingFunctions:
+ - EqualsProto
+ - EquivToProto
+ - PARSE_PARTIAL_TEXT_PROTO
+ - PARSE_TEST_PROTO
+ - PARSE_TEXT_PROTO
+ - ParseTextOrDie
+ - ParseTextProtoOrDie
+ CanonicalDelimiter: ''
+ BasedOnStyle: google
+ReflowComments: true
+SortIncludes: true
+SortUsingDeclarations: true
+SpaceAfterCStyleCast: false
+SpaceAfterLogicalNot: false
+SpaceAfterTemplateKeyword: true
+SpaceBeforeAssignmentOperators: true
+SpaceBeforeCpp11BracedList: false
+SpaceBeforeCtorInitializerColon: true
+SpaceBeforeInheritanceColon: true
+SpaceBeforeParens: ControlStatements
+SpaceBeforeRangeBasedForLoopColon: true
+SpaceInEmptyBlock: false
+SpaceInEmptyParentheses: false
+SpacesBeforeTrailingComments: 2
+SpacesInAngles: false
+SpacesInConditionalStatement: false
+SpacesInContainerLiterals: true
+SpacesInCStyleCastParentheses: false
+SpacesInParentheses: false
+SpacesInSquareBrackets: false
+SpaceBeforeSquareBrackets: false
+Standard: Auto
+StatementMacros:
+ - Q_UNUSED
+ - QT_REQUIRE_VERSION
+TabWidth: 8
+UseCRLF: false
+UseTab: Never
+...
+
diff --git a/third_party/codec2/.github/workflows/cmake-sm1000.yml b/third_party/codec2/.github/workflows/cmake-sm1000.yml
new file mode 100644
index 0000000..ab24b5d
--- /dev/null
+++ b/third_party/codec2/.github/workflows/cmake-sm1000.yml
@@ -0,0 +1,43 @@
+name: Build SM1000
+
+on: [pull_request]
+
+env:
+ # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
+ BUILD_TYPE: Debug
+
+jobs:
+ build:
+ # The CMake configure and build commands are platform agnostic and should work equally
+ # well on Windows or Mac. You can convert this to a matrix build if you need
+ # cross-platform coverage.
+ # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
+ runs-on: ubuntu-22.04
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install packages
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install octave octave-common octave-signal liboctave-dev gnuplot sox p7zip-full python3-numpy valgrind
+
+ - name: Install ST Standard Peripheral Library (SM1000)
+ working-directory: ${{github.workspace}}/stm32
+ shell: bash
+ run: git clone https://github.com/whimsicalraps/STM32F4xx_DSP_StdPeriph_Lib
+
+ - name: Install SM1000 prerequisites
+ working-directory: ${{github.workspace}}/stm32
+ shell: bash
+ run: sudo apt install gcc-arm-none-eabi
+
+ - name: Build SM1000
+ working-directory: ${{github.workspace}}/stm32
+ shell: bash
+ run: |
+ mkdir build_stm32
+ cd build_stm32
+ cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/STM32_Toolchain.cmake -DPERIPHLIBDIR=${{github.workspace}}/stm32/STM32F4xx_DSP_StdPeriph_Lib ..
+ make
diff --git a/third_party/codec2/.github/workflows/cmake.yml b/third_party/codec2/.github/workflows/cmake.yml
new file mode 100644
index 0000000..8550384
--- /dev/null
+++ b/third_party/codec2/.github/workflows/cmake.yml
@@ -0,0 +1,58 @@
+name: Build Codec2 for Linux
+
+on: [pull_request]
+
+env:
+ # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
+ BUILD_TYPE: Debug
+
+jobs:
+ build:
+ # The CMake configure and build commands are platform agnostic and should work equally
+ # well on Windows or Mac. You can convert this to a matrix build if you need
+ # cross-platform coverage.
+ # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
+ runs-on: ubuntu-22.04
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install packages
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install octave octave-common octave-signal liboctave-dev gnuplot sox p7zip-full python3-numpy valgrind clang-format texlive-latex-base texlive-fonts-recommended texlive-fonts-extra texlive-latex-extra texlive-science texmaker texlive-bibtex-extra
+
+ - name: Create Build Directory
+ shell: bash
+ run: mkdir $GITHUB_WORKSPACE/build_linux
+
+ - name: Configure codec2 CMake
+ shell: bash
+ working-directory: ${{github.workspace}}/build_linux
+ run: cmake -DUNITTEST=1 $GITHUB_WORKSPACE
+
+ - name: Build LPCNet and Run ctests
+ shell: bash
+ run: |
+ cd $HOME
+ git clone https://github.com/drowe67/LPCNet.git
+ cd LPCNet && mkdir -p build_linux && cd build_linux
+ cmake .. && make && ctest
+
+ - name: Build codec2 with LPCNet
+ working-directory: ${{github.workspace}}/build_linux
+ shell: bash
+ run: |
+ cmake -DLPCNET_BUILD_DIR=$HOME/LPCNet/build_linux -DUNITTEST=1 $GITHUB_WORKSPACE
+ make -j4
+
+ - name: Run ctests
+ working-directory: ${{github.workspace}}/build_linux
+ shell: bash
+ run: ctest --output-on-failure
+
+ - name: Test library installation
+ working-directory: ${{github.workspace}}/build_linux
+ shell: bash
+ run: cmake --install . --prefix "$HOME/codec2_install" && rm -rf "$HOME/codec2_install"
diff --git a/third_party/codec2/.gitignore b/third_party/codec2/.gitignore
new file mode 100644
index 0000000..edcb225
--- /dev/null
+++ b/third_party/codec2/.gitignore
@@ -0,0 +1,8 @@
+build_linux
+stm32/build_stm32
+stm32/libstm32f4.a
+stm32/unittest/lib/python/__pycache__/
+stm32/unittest/src/libstm32f4.a
+stm32/unittest/src/*.map
+stm32/unittest/test_run/
+*.pyc
diff --git a/third_party/codec2/CMakeLists.txt b/third_party/codec2/CMakeLists.txt
new file mode 100644
index 0000000..e62c58a
--- /dev/null
+++ b/third_party/codec2/CMakeLists.txt
@@ -0,0 +1,1434 @@
+#
+# Codec2 - Next-Generation Digital Voice for Two-Way Radio
+#
+# CMake configuration contributed by Richard Shaw (KF5OIM)
+# Please report questions, comments, problems, or patches to the freetel
+# mailing list: https://lists.sourceforge.net/lists/listinfo/freetel-codec2
+#
+
+# Note: this has to be at the beginning of the file in order for CMake to
+# actually recognize this override (vs. simply telling the macOS build toolchain
+# to mandate the current release).
+set(CMAKE_OSX_DEPLOYMENT_TARGET "10.9" CACHE STRING "Minimum OS X deployment version")
+
+cmake_minimum_required(VERSION 3.13)
+project(CODEC2
+ VERSION 1.2.0
+ DESCRIPTION "Next-Generation Digital Voice for Two-Way Radio"
+ HOMEPAGE_URL "https://www.rowetel.com/codec2.html"
+ LANGUAGES C
+ )
+
+include(GNUInstallDirs)
+mark_as_advanced(CLEAR
+ CMAKE_INSTALL_BINDIR
+ CMAKE_INSTALL_INCLUDEDIR
+ CMAKE_INSTALL_LIBDIR
+)
+
+#
+# Prevent in-source builds
+# If an in-source build is attempted, you will still need to clean up a few
+# files manually.
+#
+set(CMAKE_DISABLE_SOURCE_CHANGES ON)
+set(CMAKE_DISABLE_IN_SOURCE_BUILD ON)
+if("${CMAKE_SOURCE_DIR}" STREQUAL "${CMAKE_BINARY_DIR}")
+ message(FATAL_ERROR "In-source builds in ${CMAKE_BINARY_DIR} are not "
+ "allowed, please remove ./CMakeCache.txt and ./CMakeFiles/, create a "
+ "separate build directory and run cmake from there.")
+endif("${CMAKE_SOURCE_DIR}" STREQUAL "${CMAKE_BINARY_DIR}")
+
+# Set default build type
+if(NOT CMAKE_BUILD_TYPE)
+ set(CMAKE_BUILD_TYPE "Debug")
+endif()
+
+# Build universal ARM64 and x86_64 binaries on Mac.
+if(BUILD_OSX_UNIVERSAL)
+set(CMAKE_OSX_ARCHITECTURES "x86_64;arm64")
+endif(BUILD_OSX_UNIVERSAL)
+set(CMAKE_OSX_DEPLOYMENT_TARGET "10.9" CACHE STRING "Minimum OS X deployment version")
+
+#
+# Find the git hash if this is a working copy.
+#
+if(EXISTS ${CMAKE_SOURCE_DIR}/.git)
+ find_package(Git)
+ if(Git_FOUND)
+ execute_process(
+ COMMAND "${GIT_EXECUTABLE}" rev-parse --short HEAD
+ WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
+ RESULT_VARIABLE res
+ OUTPUT_VARIABLE CODEC2_HASH
+ ERROR_QUIET
+ OUTPUT_STRIP_TRAILING_WHITESPACE)
+ message(STATUS "Codec2 current git hash: ${CODEC2_HASH}")
+ add_definitions(-DGIT_HASH="${CODEC2_HASH}")
+ else()
+ message(WARNING "Git not found. Can not determine current commit hash.")
+ add_definitions(-DGIT_HASH="Unknown")
+ endif()
+else()
+ add_definitions(-DGIT_HASH="None")
+endif()
+
+set(ARCHIVE_NAME "codec2-${CODEC2_VERSION_MAJOR}.${CODEC2_VERSION_MINOR}.${CODEC2_VERSION_PATCH}")
+add_custom_target(dist
+ COMMAND git archive --prefix=${ARCHIVE_NAME}/ HEAD
+ | bzip2 > ${CMAKE_BINARY_DIR}/${ARCHIVE_NAME}.tar.bz2
+ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
+
+# Set default C flags.
+set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wno-strict-overflow")
+
+# Check for what C standard is supported.
+if(NOT WIN32)
+include(CheckCCompilerFlag)
+CHECK_C_COMPILER_FLAG("-std=gnu11" COMPILER_SUPPORTS_GNU11)
+CHECK_C_COMPILER_FLAG("-std=gnu99" COMPILER_SUPPORTS_GNU99)
+
+if(COMPILER_SUPPORTS_GNU11)
+ set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=gnu11")
+elseif(COMPILER_SUPPORTS_GNU99)
+ set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=gnu99")
+else()
+ message(SEND_ERROR "Compiler doesn't seem to support at least gnu99, might cause problems" )
+endif()
+endif(NOT WIN32)
+
+# -fPIC is implied on MinGW...
+if((NOT WIN32) AND (NOT MICROCONTROLLER_BUILD))
+ set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fPIC")
+endif()
+
+set(CMAKE_C_FLAGS_DEBUG "-g -O2 -DDUMP")
+set(CMAKE_C_FLAGS_RELEASE "-O3")
+
+#
+# Setup Windows/MinGW specifics here.
+#
+if(MINGW)
+ message(STATUS "System is MinGW.")
+endif(MINGW)
+
+#
+# Default options
+#
+option(BUILD_SHARED_LIBS
+ "Build shared library. Set to OFF for static library." ON)
+option(UNITTEST "Build unittest binaries." OFF)
+
+# LPCNet needs to be bootstrapped because codec2 and freedvlpcnet are
+# cross dependent.
+option(LPCNET "Build codec2 with LPCNet support." OFF)
+set(LPCNET_BUILD_DIR FALSE CACHE PATH "Location of lpcnet build tree.")
+# Setting LPCNET_BUILD_DIR implies LPCNET=ON
+if(LPCNET_BUILD_DIR)
+ set(LPCNET ON)
+endif()
+
+
+include(CheckIncludeFiles)
+check_include_files("stdlib.h" HAVE_STDLIB_H)
+check_include_files("string.h" HAVE_STRING_H)
+
+include(CheckSymbolExists)
+# Check if _GNU_SOURCE is available.
+if (NOT DEFINED _GNU_SOURCE)
+ check_symbol_exists(__GNU_LIBRARY__ "features.h" _GNU_SOURCE)
+
+ if (NOT _GNU_SOURCE)
+ unset(_GNU_SOURCE CACHE)
+ check_symbol_exists(_GNU_SOURCE "features.h" _GNU_SOURCE)
+ endif()
+endif()
+
+if (_GNU_SOURCE)
+ add_definitions(-D_GNU_SOURCE=1)
+endif()
+
+check_symbol_exists(floor math.h HAVE_FLOOR)
+check_symbol_exists(ceil math.h HAVE_CEIL)
+check_symbol_exists(pow math.h HAVE_POW)
+check_symbol_exists(sqrt math.h HAVE_SQRT)
+check_symbol_exists(sin math.h HAVE_SIN)
+check_symbol_exists(cos math.h HAVE_COS)
+check_symbol_exists(atan2 math.h HAVE_ATAN2)
+check_symbol_exists(log10 math.h HAVE_LOG10)
+check_symbol_exists(round math.h HAVE_ROUND)
+check_symbol_exists(getopt getopt.h HAVE_GETOPT)
+
+configure_file ("${PROJECT_SOURCE_DIR}/cmake/config.h.in"
+ "${PROJECT_BINARY_DIR}/config.h" )
+# Output path is such that #include in codec2.h works
+set(CODEC2_VERSION_PATH "${PROJECT_BINARY_DIR}/codec2")
+configure_file ("${PROJECT_SOURCE_DIR}/cmake/version.h.in"
+ "${CODEC2_VERSION_PATH}/version.h" )
+include_directories(${PROJECT_BINARY_DIR})
+
+# CMake Package setup
+#include(CMakePackageConfigHelpers)
+#configure_package_config_file(cmake/codec2-config.cmake.in
+# ${CMAKE_CURRENT_BINARY_DIR}/codec2-config.cmake
+# INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/codec2
+# PATH_VARS CMAKE_INSTALL_INCLUDEDIR
+#)
+
+
+#
+# Find lpcnet library
+#
+if(LPCNET)
+ if(LPCNET_BUILD_DIR)
+ # Theoretically this shouldn't be needed as we're also defining LPCNET_BUILD_DIR
+ # in the PATHS section below. But on Fedora 37, CMake can't find LPCNet (at least
+ # in Docker) without this.
+ set(lpcnetfreedv_DIR ${LPCNET_BUILD_DIR})
+
+ find_package(lpcnetfreedv REQUIRED
+ PATHS ${LPCNET_BUILD_DIR}
+ NO_DEFAULT_PATH
+ CONFIGS lpcnetfreedv.cmake
+ )
+ if(lpcnetfreedv_FOUND)
+ message(STATUS "liblpcnetfreedv found in build tree.")
+ add_definitions("-D__LPCNET__")
+ else()
+ message(FATAL_ERROR "LPCNet include/library not found in build tree.")
+ endif()
+ else()
+ find_package(lpcnetfreedv REQUIRED)
+ if(lpcnetfreedv_FOUND)
+ add_definitions("-D__LPCNET__")
+ message(STATUS "liblpcnetfreedv found.")
+ else()
+ message(FATAL_ERROR "lpcnetfreedv library not found.")
+ endif()
+ endif()
+endif()
+
+
+#
+# codec2 library and demo apps
+#
+add_subdirectory(src)
+add_subdirectory(demo)
+
+
+if(UNITTEST)
+ # Pthread Library
+ find_package(Threads REQUIRED)
+ message(STATUS "Threads library flags: ${CMAKE_THREAD_LIBS_INIT}")
+
+ add_subdirectory(unittest)
+endif(UNITTEST)
+
+message(STATUS "Build type is: " ${CMAKE_BUILD_TYPE})
+string(TOUPPER ${CMAKE_BUILD_TYPE} _FLAGS)
+if(_FLAGS STREQUAL "NONE")
+ message(STATUS "Compiler Flags: " ${CMAKE_C_FLAGS})
+else()
+ message(STATUS "Compiler Flags: " ${CMAKE_C_FLAGS} ${CMAKE_C_FLAGS_${_FLAGS}})
+endif()
+message(STATUS "Libraries linked: " ${CMAKE_REQUIRED_LIBRARIES})
+
+set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Next-Generation Digital Voice for Two-Way Radio")
+set(CPACK_PACKAGE_VENDOR "CMake")
+set(CPACK_PACKAGE_DESCRIPTION_FILE "${CMAKE_CURRENT_SOURCE_DIR}/README.md")
+set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/COPYING")
+set(CPACK_PACKAGE_VERSION_MAJOR ${CODEC2_VERSION_MAJOR})
+set(CPACK_PACKAGE_VERSION_MINOR ${CODEC2_VERSION_MINOR})
+if(CODEC2_VERSION_PATCH)
+ set(CPACK_PACKAGE_VERSION_PATCH ${CODEC2_VERSION_PATCH})
+else()
+ set(CPACK_PACKAGE_VERSION_PATCH 0)
+endif()
+
+# Return the date (yyyy-mm-dd)
+string(TIMESTAMP DATE_RESULT "%Y-%m-%d" UTC)
+message(STATUS "Compilation date = XX${DATE_RESULT}XX")
+
+set(CPACK_PACKAGE_VERSION_PATCH "${CPACK_PACKAGE_VERSION_PATCH}-${DATE_RESULT}-${CODEC2_HASH}")
+
+if(WIN32)
+ #
+ # Cpack NSIS installer configuration for Windows.
+ # See: http://nsis.sourceforge.net/Download
+ #
+
+ # Detect if we're doing a 32-bit or 64-bit windows build.
+ if(${CMAKE_SIZEOF_VOID_P} EQUAL 8)
+ set(CMAKE_CL_64 TRUE)
+ endif()
+ configure_file(cmake/GetDependencies.cmake.in cmake/GetDependencies.cmake
+ @ONLY
+ )
+ install(SCRIPT ${CMAKE_BINARY_DIR}/cmake/GetDependencies.cmake)
+ set(CPACK_PACKAGE_INSTALL_DIRECTORY "Codec2")
+ set(CPACK_CREATE_DESKTOP_LINKS "")
+ set(CPACK_NSIS_DISPLAY_NAME "${CPACK_PACKAGE_INSTALL_DIRECTORY}")
+ set(CPACK_NSIS_URL_INFO_ABOUT "http://rowetel.com/codec2.html")
+ set(CPACK_NSIS_MODIFY_PATH ON)
+ include(CPack)
+elseif(UNIX AND NOT APPLE)
+ # Linux packaging
+ SET(CPACK_GENERATOR "DEB")
+ SET(CPACK_DEBIAN_PACKAGE_MAINTAINER "Mooneer Salem ") #required
+ SET(CPACK_DEB_COMPONENT_INSTALL ON)
+ SET(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
+ SET(CPACK_DEBIAN_ENABLE_COMPONENT_DEPENDS ON)
+ SET(CPACK_DEBIAN_LIB_PACKAGE_NAME "codec2")
+ SET(CPACK_DEBIAN_PACKAGE_DEPENDS "lpcnet (>= 0.3.0)")
+ include(CPack)
+ cpack_add_component(lib REQUIRED)
+ cpack_add_component(dev DEPENDS lib)
+endif(WIN32)
+
+########################################################################
+# Create Pkg Config File
+########################################################################
+configure_file(
+ ${CMAKE_CURRENT_SOURCE_DIR}/codec2.pc.in
+ ${CMAKE_CURRENT_BINARY_DIR}/codec2.pc
+ @ONLY
+)
+
+install(
+ FILES ${CMAKE_CURRENT_BINARY_DIR}/codec2.pc
+ DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig
+ COMPONENT "codec2_devel"
+)
+
+##################################################################
+# Tests
+##################################################################
+
+if(UNITTEST)
+ include(CTest)
+ enable_testing()
+
+ add_test(NAME test_clang_format
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR};
+ clang-format --dry-run --Werror src/*.c src/*.h unittest/*.c demo/*.c")
+
+ add_test(NAME test_codec2_doc
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/doc;
+ make clean;
+ CODEC2_SRC=${CMAKE_CURRENT_SOURCE_DIR} CODEC2_BINARY=${CMAKE_CURRENT_BINARY_DIR} JOBNAME=test make")
+
+ add_test(NAME test_freedv_get_hash
+ COMMAND sh -c "${CMAKE_CURRENT_BINARY_DIR}/unittest/thash")
+
+if(UNIX) # Uses pthreads
+ add_test(NAME test_fifo
+ COMMAND $
+ )
+endif()
+
+ # 16<->8 kHz float resamplers
+ add_test(NAME test_fdmdv_16to8
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ ${CMAKE_CURRENT_BINARY_DIR}/unittest/t16_8;
+ DISPLAY=\"\" echo \"diff_fft_mag('in8.raw','out8.raw'); quit;\" | octave-cli -qf
+ ")
+ set_tests_properties(test_fdmdv_16to8 PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ # 16<->8 kHz short (int16) resamplers
+ add_test(NAME test_fdmdv_16to8_short
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ ${CMAKE_CURRENT_BINARY_DIR}/unittest/t16_8_short;
+ DISPLAY=\"\" echo \"diff_fft_mag('in8_short.raw','out8_short.raw'); quit;\" | octave-cli -qf
+ ")
+ set_tests_properties(test_fdmdv_16to8_short PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ # 48<->8 kHz float resamplers
+ add_test(NAME test_fdmdv_48to8_short
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ ${CMAKE_CURRENT_BINARY_DIR}/unittest/t48_8_short;
+ DISPLAY=\"\" echo \"diff_fft_mag('in8.raw','out8.raw'); quit;\" | octave-cli -qf
+ ")
+ set_tests_properties(test_fdmdv_48to8_short PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ # 48<->8 kHz short resamplers
+ add_test(NAME test_fdmdv_48to8
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ ${CMAKE_CURRENT_BINARY_DIR}/unittest/t48_8;
+ DISPLAY=\"\" echo \"diff_fft_mag('in8.raw','out8.raw'); quit;\" | octave-cli -qf
+ ")
+ set_tests_properties(test_fdmdv_48to8 PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ # Basic sanity check of Quisk complex band pass filter. Note complex filtering cosw(wn) gives
+ # just the +ve freq exp(jwn) so output power is 0.5 input power
+ add_test(NAME test_quisk_filter
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ ${CMAKE_CURRENT_BINARY_DIR}/unittest/mksine in.raw 1500 1;
+ cat in.raw | ${CMAKE_CURRENT_BINARY_DIR}/unittest/tquisk_filter |
+ sox -t .s16 -r 8000 -c 1 - -t .s16 out.raw vol 2;
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" echo \"diff_fft_mag('in.raw','out.raw'); quit;\" | octave-cli -qf
+ ")
+ set_tests_properties(test_quisk_filter PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ add_test(NAME test_CML_ldpcut
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave; CTEST_SINGLE=1 octave-cli -qf ldpcut.m")
+ set_tests_properties(test_CML_ldpcut PROPERTIES PASS_REGULAR_EXPRESSION "Nerr: 0")
+
+ add_test(NAME test_CML_ldpcut_one_stuffing
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave; CTEST_ONE_STUFFING=1 octave-cli -qf ldpcut.m")
+ set_tests_properties(test_CML_ldpcut_one_stuffing PROPERTIES PASS_REGULAR_EXPRESSION "Ferrs: 0")
+
+ # Golay (23,11) unit tests
+ add_test(NAME test_golay23 COMMAND sh -c "${CMAKE_CURRENT_BINARY_DIR}/unittest/golay23")
+ add_test(NAME test_golay23_runtime_tables COMMAND sh -c "${CMAKE_CURRENT_BINARY_DIR}/unittest/golay23_runtime_tables")
+
+ # check channel simulator measures correct Peak to Average Power Ratio (about 0dB) with a sine wave input signal
+ add_test(NAME test_ch_papr
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR};
+ ./unittest/mksine - 1000 10 | ./src/ch - /dev/null --ctest")
+
+ add_test(NAME test_codec2_700c_octave_port
+ COMMAND sh -c "
+ cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./c2sim ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw --phase0 --postfilter --dump hts1a --lpc 10 --dump_pitch_e hts1a_pitche.txt;
+ cd ${CMAKE_CURRENT_BINARY_DIR}/unittest; ./tnewamp1 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw;
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval 'tnewamp1(\"${CMAKE_CURRENT_BINARY_DIR}/src/hts1a\", \"${CMAKE_CURRENT_BINARY_DIR}/unittest\")'")
+ set_tests_properties(test_codec2_700c_octave_port PROPERTIES PASS_REGULAR_EXPRESSION "fails: 0")
+
+ # -------------------------------------------------------------------------
+ # FDMDV Modem
+ # -------------------------------------------------------------------------
+
+ add_test(NAME test_FDMDV_modem_octave_ut
+ COMMAND sh -c "
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave/;
+ DISPLAY=\"\" octave-cli -qf fdmdv_ut.m")
+ set_tests_properties(test_FDMDV_modem_octave_ut PROPERTIES PASS_REGULAR_EXPRESSION "errors......: 0")
+
+ add_test(NAME test_FDMDV_modem_octave_mod_demod
+ COMMAND sh -c "
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave/;
+ echo \"fdmdv_mod('test.raw',1400); fdmdv_demod('test.raw',1400); quit\" | DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_FDMDV_modem_octave_mod_demod PROPERTIES PASS_REGULAR_EXPRESSION "0 errors")
+
+ add_test(NAME test_FDMDV_modem_octave_port
+ COMMAND sh -c "$ && DISPLAY=\"\" octave-cli --no-gui -qf ${CMAKE_CURRENT_SOURCE_DIR}/octave/tfdmdv.m"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/octave)
+ set_tests_properties(test_FDMDV_modem_octave_port PROPERTIES PASS_REGULAR_EXPRESSION "fails: 0")
+
+ add_test(NAME test_FDMDV_modem_octave_c
+ COMMAND sh -c "
+ cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./fdmdv_get_test_bits - 14000 | ./fdmdv_mod - - |
+ ./fdmdv_demod - - 14 demod_dump.txt | ./fdmdv_put_test_bits - ;
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave/;
+ DISPLAY=\"\" octave-cli -qf fdmdv_ut.m")
+ set_tests_properties(test_FDMDV_modem_octave_c PROPERTIES PASS_REGULAR_EXPRESSION "errors......: 0")
+
+ # -------------------------------------------------------------------------
+ # COHPSK Modem
+ # -------------------------------------------------------------------------
+
+ add_test(NAME test_COHPSK_modem_octave_port
+ COMMAND sh -c "$ && DISPLAY=\"\" octave-cli --no-gui -qf ${CMAKE_CURRENT_SOURCE_DIR}/octave/tcohpsk.m"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/octave)
+ set_tests_properties(test_COHPSK_modem_octave_port PROPERTIES PASS_REGULAR_EXPRESSION "fails: 0")
+
+ add_test(NAME test_COHPSK_modem_AWGN_BER
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./cohpsk_get_test_bits - 5600 |
+ ./cohpsk_mod - - |
+ ./ch - - --No -30 --Fs 7500 |
+ ./cohpsk_demod - - |
+ ./cohpsk_put_test_bits -"
+ )
+
+ add_test(NAME test_COHPSK_modem_freq_offset
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./cohpsk_get_test_bits - 5600 |
+ ./cohpsk_mod - - |
+ ./ch - - --No -40 -f -30 --Fs 7500 |
+ ./cohpsk_demod - - |
+ ./cohpsk_put_test_bits -"
+ )
+
+ # -------------------------------------------------------------------------
+ # OFDM Modem
+ # -------------------------------------------------------------------------
+
+ add_test(NAME test_OFDM_qam16
+ COMMAND sh -c "${CMAKE_CURRENT_BINARY_DIR}/unittest/tqam16")
+
+ add_test(NAME test_OFDM_modem_octave_port
+ COMMAND sh -c "PATH_TO_TOFDM=${CMAKE_CURRENT_BINARY_DIR}/unittest/tofdm DISPLAY=\"\" octave-cli --no-gui -qf ${CMAKE_CURRENT_SOURCE_DIR}/octave/tofdm.m"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/octave)
+ set_tests_properties(test_OFDM_modem_octave_port PROPERTIES PASS_REGULAR_EXPRESSION "fails: 0")
+
+ add_test(NAME test_OFDM_modem_octave_port_Nc_31
+ COMMAND sh -c "NC=31 PATH_TO_TOFDM=${CMAKE_CURRENT_BINARY_DIR}/unittest/tofdm DISPLAY=\"\" octave-cli --no-gui -qf ${CMAKE_CURRENT_SOURCE_DIR}/octave/tofdm.m"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/octave)
+ set_tests_properties(test_OFDM_modem_octave_port_Nc_31 PROPERTIES PASS_REGULAR_EXPRESSION "fails: 0")
+
+ add_test(NAME test_OFDM_modem_octave_qam16_uncoded
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo \"ofdm_tx('test_qam16.raw','qam16c1',3,12,'awgn','bursts',3); ofdm_rx('test_qam16.raw','qam16c1', 'passber', 0.05, 'packetsperburst', 1); quit\" |
+ DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_octave_qam16_uncoded PROPERTIES PASS_REGULAR_EXPRESSION "Pass")
+
+ add_test(NAME test_OFDM_modem_esno_est_octave
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo 'pkg load signal; esno_est; esno_est_tests_octave' |
+ PATH_TO_UNITEST=${CMAKE_CURRENT_BINARY_DIR}/unittest/ DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_esno_est_octave PROPERTIES PASS_REGULAR_EXPRESSION "AWGN Pass.*MPP Pass")
+
+ add_test(NAME test_OFDM_modem_esno_est_c
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo 'pkg load signal; esno_est; esno_est_tests_c' |
+ PATH_TO_UNITEST=${CMAKE_CURRENT_BINARY_DIR}/unittest/ DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_esno_est_c PROPERTIES PASS_REGULAR_EXPRESSION "AWGN Pass.*MPP Pass")
+
+
+ # ---------------------------------- Data Mode burst acquisition tests ----------------------------------
+
+ add_test(NAME test_OFDM_modem_octave_burst_acq
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo \"ctest=1; ofdm_acquisition; quit\" | DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_octave_burst_acq PROPERTIES PASS_REGULAR_EXPRESSION "P.acq. = 1.00")
+
+ add_test(NAME test_OFDM_modem_octave_datac0_postamble
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo \"ofdm_tx('test_datac0.raw','datac0',1,100,'awgn','bursts',3); ofdm_rx('test_datac0.raw','datac0','packetsperburst',1,'postambletest','passber', 1E-6); quit\" |
+ DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_octave_datac0_postamble PROPERTIES PASS_REGULAR_EXPRESSION "Pass")
+
+ # Check C port of burst acquisition
+ add_test(NAME test_OFDM_modem_burst_acq_port
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo \"tofdm_acq; quit\" | PATH_TO_UNITTEST=${CMAKE_CURRENT_BINARY_DIR}/unittest DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_burst_acq_port PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ # Give uncoded Octave burst data modem a workout on a poor channel (0dB SNR MPP)
+ add_test(NAME test_OFDM_modem_octave_datac0_mpp
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo \"ofdm_tx('test_datac0.raw','datac0',1,0,'mpp','bursts',10); \
+ ofdm_rx('test_datac0.raw','datac0','packetsperburst',1,'passpacketcount',9);\
+ quit\" |
+ DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_octave_datac0_mpp PROPERTIES PASS_REGULAR_EXPRESSION "Pass")
+
+ # Same for coded Octave burst data modem - look out for bit rot as simulations evolve ....
+ add_test(NAME test_OFDM_modem_octave_datac0_mpp_coded
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ echo \"ofdm_ldpc_tx('test_datac0.raw','datac0',1,0,'mpp','bursts',10); \
+ ofdm_ldpc_rx('test_datac0.raw','datac0','packetsperburst',1,'passpacketcount',9);\
+ quit\" |
+ DISPLAY=\"\" octave-cli")
+ set_tests_properties(test_OFDM_modem_octave_datac0_mpp_coded PROPERTIES PASS_REGULAR_EXPRESSION "Pass")
+
+ # Check Octave and C compressed waveforms are about the same
+ add_test(NAME test_OFDM_modem_datac0_compression
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ ./check_comp.sh ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}/src")
+
+ # ---------------------------------- ofdm_mod/demod level C modem tests ----------------------------------
+
+ # noise free uncoded 700D test, including reading and writing payload bits
+ add_test(NAME test_OFDM_modem_700D
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_get_test_bits - |
+ ./ofdm_mod |
+ ./ofdm_demod --testframes > /dev/null")
+
+ # noise free coded 700D test, including reading and writing payload bits
+ add_test(NAME test_OFDM_modem_700D_ldpc
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_get_test_bits - --length 112 |
+ ./ofdm_mod --ldpc |
+ ./ofdm_demod --ldpc --testframes > /dev/null")
+
+ # noise free 2020 test, including reading and writing payload bits. fsk_*_test_bits
+ # used as it does it's own frame sync
+ add_test(NAME test_OFDM_modem_2020_ldpc
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./fsk_get_test_bits - 5000 |
+ ./ofdm_mod --ldpc --mode 2020 |
+ ./ofdm_demod --ldpc --mode 2020 |
+ ./fsk_put_test_bits - -q")
+
+ add_test(NAME test_OFDM_modem_AWGN_BER
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --in /dev/zero --ldpc --testframes 60 --txbpf |
+ ./ch - - --No -20 -f -50 |
+ ./ofdm_demod --out /dev/null --testframes --ldpc --verbose 1"
+ )
+
+ add_test(NAME test_OFDM_modem_fading_BER
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./ofdm_fade.sh ${CMAKE_CURRENT_BINARY_DIR}/unittest")
+
+ add_test(NAME test_OFDM_modem_phase_est_bw
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./ofdm_phase_est_bw.sh ${CMAKE_CURRENT_BINARY_DIR}/unittest")
+
+ add_test(NAME test_OFDM_modem_time_sync_700D
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./ofdm_time_sync.sh 700D")
+
+if(LPCNET)
+ add_test(NAME test_OFDM_modem_time_sync_2020
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./ofdm_time_sync.sh 2020")
+endif()
+
+ # 700E at a little above AWGN operating point
+ add_test(NAME test_OFDM_modem_700E_AWGN
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --in /dev/zero --testframes 10 --ldpc --mode 700E |
+ ./ch - - --No -22 |
+ ./ofdm_demod --mode 700E --ldpc --testframes -v 2 > /dev/null")
+
+ # 2020B AWGN test
+ add_test(NAME test_OFDM_modem_2020B_AWGN
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --in /dev/zero --testframes 10 --mode 2020B --ldpc --clip --txbpf |
+ ./ch - - --No -19 |
+ ./ofdm_demod --mode 2020B --testframes --ldpc -v 2 > /dev/null")
+
+ # -------------------------------------------------------------------------
+ # OFDM Data modes
+ # -------------------------------------------------------------------------
+
+ # To integrate a new mode/waveform we prototype in Octave, get the core OFDM modem
+ # running in C (ofdm_mod & ofdm_demod), then the FreeDV API (frredv_tx & freedv_rx).
+ # Here we test Octave and the C versions of the OFDM modem working together, to help
+ # prevent any bit rot between them
+
+ # DATAC0 burst mode Octave Tx, C Rx
+ add_test(NAME test_OFDM_modem_datac0_octave_burst
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval \"ofdm_ldpc_tx('${CMAKE_CURRENT_BINARY_DIR}/test.raw','datac0',1,100,'awgn','bursts',3)\";
+ cd ${CMAKE_CURRENT_BINARY_DIR};
+ cat test.raw | ./src/ofdm_demod --mode datac0 --out /dev/null --testframes --ldpc --verbose 1 --packetsperburst 1")
+ set_tests_properties(test_OFDM_modem_datac0_octave_burst PROPERTIES PASS_REGULAR_EXPRESSION "Coded PER: 0.0000 Tpkts: 3")
+
+ # DATAC1 C Tx, Octave Rx
+ add_test(NAME test_OFDM_modem_datac1_octave
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR};
+ ./src/ofdm_mod --mode datac1 --in /dev/zero --testframes 20 --verbose 1 --ldpc > test.raw;
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval 'ofdm_ldpc_rx(\"${CMAKE_CURRENT_BINARY_DIR}/test.raw\",\"datac1\")'")
+ set_tests_properties(test_OFDM_modem_datac1_octave PROPERTIES PASS_REGULAR_EXPRESSION "Coded PER: 0.0000 Pckts: 4")
+
+ # DATAC3 C Tx, Octave Rx
+ add_test(NAME test_OFDM_modem_datac3_octave
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR};
+ ./src/ofdm_mod --mode datac3 --in /dev/zero --testframes 20 --verbose 1 --ldpc > test.raw;
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval 'ofdm_ldpc_rx(\"${CMAKE_CURRENT_BINARY_DIR}/test.raw\",\"datac3\")'")
+ set_tests_properties(test_OFDM_modem_datac3_octave PROPERTIES PASS_REGULAR_EXPRESSION "Coded PER: 0.0000 Pckts: 5")
+
+ # DATAC1 C Tx, C Rx, uncoded
+ add_test(NAME test_OFDM_modem_datac1
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --mode datac1 --in /dev/zero --testframes 10 --verbose 1 |
+ ./ofdm_demod --mode datac1 --out /dev/null --testframes --verbose 1")
+
+ # DATAC1 C Tx, C Rx, coded
+ add_test(NAME test_OFDM_modem_datac1_ldpc
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --mode datac1 --in /dev/zero --testframes 10 --ldpc --verbose 1 |
+ ./ofdm_demod --mode datac1 --out /dev/null --testframes --ldpc --verbose 1")
+
+ # DATAC0 C Tx, C Rx, coded, burst mode
+ add_test(NAME test_OFDM_modem_datac0_ldpc_burst
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --mode datac0 --in /dev/zero --testframes 1 --verbose 1 --ldpc --bursts 3 |
+ ./ch - - --No -17 |
+ ./ofdm_demod --mode datac0 --out /dev/null --testframes --ldpc --verbose 2 --packetsperburst 1")
+
+ # DATAC4 C Tx, Octave Rx, burst mode
+ add_test(NAME test_OFDM_modem_datac4_octave
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR};
+ ./src/ofdm_mod --mode datac4 --in /dev/zero --testframes 1 --verbose 1 --ldpc --bursts 5 > test.raw;
+ cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval 'ofdm_ldpc_rx(\"${CMAKE_CURRENT_BINARY_DIR}/test.raw\",\"datac4\",\"packetsperburst\",1)'")
+ set_tests_properties(test_OFDM_modem_datac3_octave PROPERTIES PASS_REGULAR_EXPRESSION "Coded PER: 0.0000 Pckts: 5")
+
+ # DATAC13 Octave Tx, C Rx, burst mode
+ add_test(NAME test_OFDM_modem_datac13_octave
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval 'ofdm_ldpc_tx(\"${CMAKE_CURRENT_BINARY_DIR}/src/test.raw\",\"datac13\",1,3,\"awgn\",\"bursts\",5)';
+ cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ cat test.raw | ./ofdm_demod --mode datac13 --out /dev/null --testframes --ldpc --verbose 2 --packetsperburst 1")
+
+ # DATAC14 Octave Tx, C Rx, burst mode
+ add_test(NAME test_OFDM_modem_datac14_octave
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ DISPLAY=\"\" octave-cli -qf --eval 'ofdm_ldpc_tx(\"${CMAKE_CURRENT_BINARY_DIR}/src/test.raw\",\"datac14\",1,3,\"awgn\",\"bursts\",5)';
+ cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ cat test.raw | ./ofdm_demod --mode datac14 --out /dev/null --testframes --ldpc --verbose 2 --packetsperburst 1")
+
+ # DATAC4 C Tx, C Rx, burst mode
+ add_test(NAME test_OFDM_modem_datac4_ldpc_burst
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --mode datac4 --in /dev/zero --testframes 1 --verbose 1 --ldpc --bursts 10 |
+ ./ch - - --No -17 |
+ ./ofdm_demod --mode datac4 --out /dev/null --testframes --ldpc --verbose 2 --packetsperburst 1")
+
+ # DATAC13 C Tx, C Rx, burst mode
+ add_test(NAME test_OFDM_modem_datac13_ldpc_burst
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --mode datac13 --in /dev/zero --testframes 1 --verbose 1 --ldpc --bursts 10 |
+ ./ch - - --No -17 |
+ ./ofdm_demod --mode datac13 --out /dev/null --testframes --ldpc --verbose 2 --packetsperburst 1")
+
+ # DATAC14 C Tx, C Rx, burst mode
+ add_test(NAME test_OFDM_modem_datac14_ldpc_burst
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --mode datac14 --in /dev/zero --testframes 1 --verbose 1 --ldpc --bursts 10 |
+ ./ch - - --No -17 |
+ ./ofdm_demod --mode datac14 --out /dev/null --testframes --ldpc --verbose 2 --packetsperburst 1")
+
+ # -------------------------------------------------------------------------
+ # LDPC
+ # -------------------------------------------------------------------------
+
+ # tests ldpc_enc/ldpc_noise/ldpc_dec
+ add_test(NAME test_ldpc_enc_dec
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code HRA_112_112 --testframes 200 |
+ ./ldpc_noise - - 0.5 |
+ ./ldpc_dec - /dev/null --code HRA_112_112 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_HRA_56_56
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code HRA_56_56 --testframes 200 |
+ ./ldpc_noise - - 0.5 |
+ ./ldpc_dec - /dev/null --code HRA_56_56 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_212_158
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_212_158 --testframes 200 |
+ ./ldpc_noise - - -2.0 |
+ ./ldpc_dec - /dev/null --code H_212_158 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_HRAb_396_504
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code HRAb_396_504 --testframes 200 |
+ ./ldpc_noise - - -2.0 |
+ ./ldpc_dec - /dev/null --code HRAb_396_504 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_256_768_22
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_256_768_22 --testframes 200 |
+ ./ldpc_noise - - 3.0 |
+ ./ldpc_dec - /dev/null --code H_256_768_22 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_256_512_4
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_256_512_4 --testframes 200 |
+ ./ldpc_noise - - 0.5 |
+ ./ldpc_dec - /dev/null --code H_256_512_4 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_HRAa_1536_512
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code HRAa_1536_512 --testframes 200 |
+ ./ldpc_noise - - -2 |
+ ./ldpc_dec - /dev/null --code HRAa_1536_512 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_128_256_5
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_128_256_5 --testframes 200 |
+ ./ldpc_noise - - 0.5 |
+ ./ldpc_dec - /dev/null --code H_128_256_5 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_4096_8192_3d
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_4096_8192_3d --testframes 100 |
+ ./ldpc_noise - - 0.0 |
+ ./ldpc_dec - /dev/null --code H_4096_8192_3d --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_16200_9720
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_16200_9720 --testframes 10 |
+ ./ldpc_noise - - 0.5 |
+ ./ldpc_dec - /dev/null --code H_16200_9720 --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_1024_2048_4f
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_1024_2048_4f --testframes 100 |
+ ./ldpc_noise - - 0.0 |
+ ./ldpc_dec - /dev/null --code H_1024_2048_4f --sd --testframes"
+ )
+
+ add_test(NAME test_ldpc_enc_dec_H_2064_516_sparse
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ldpc_enc /dev/zero - --sd --code H_2064_516_sparse --testframes 100 |
+ ./ldpc_noise - - -2.0 |
+ ./ldpc_dec - /dev/null --code H_2064_516_sparse --sd --testframes"
+ )
+
+ # -------------------------------------------------------------------------
+ # FreeDV API
+ # -------------------------------------------------------------------------
+
+ # Test 1600 using number of frames decoded and correct rx txt channel output
+ add_test(NAME test_freedv_api_1600
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 1600 ../../raw/ve9qrp_10s.raw - | ./freedv_rx 1600 - /dev/null --txtrx 1600.txt;
+ cat 1600.txt")
+ set_tests_properties(test_freedv_api_1600 PROPERTIES PASS_REGULAR_EXPRESSION "frames decoded: 503 .*cq cq hello")
+
+ add_test(NAME test_freedv_api_700C
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700C ../../raw/ve9qrp_10s.raw - | ./freedv_rx 700C - /dev/null")
+ set_tests_properties(test_freedv_api_700C PROPERTIES PASS_REGULAR_EXPRESSION "frames decoded: 125")
+
+ add_test(NAME test_freedv_api_700D_backwards_compatability
+ COMMAND sh -c "$ 700D ${CMAKE_CURRENT_SOURCE_DIR}/raw/testframes_700d.raw /dev/null --testframes --discard"
+ )
+
+ # speech output on valid signal (at least 70000 samples), to exercise freedv_bits_to_speech() speech output logic
+ add_test(NAME test_freedv_api_700D_speech
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700D ../../raw/ve9qrp_10s.raw - |
+ ./ch - - --No -20 |
+ ./freedv_rx 700D - /dev/null --squelch -2 -vv")
+ set_tests_properties(test_freedv_api_700D_speech PROPERTIES PASS_REGULAR_EXPRESSION "frames decoded: 62 output speech samples: 7")
+
+ # no random speech output due to trial sync when listening to noise
+ add_test(NAME test_freedv_api_700D_burble
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700D ../../raw/ve9qrp.raw - |
+ ./ch - - --No -8 |
+ ./freedv_rx 700D - /dev/null --squelch -2 -vv")
+ set_tests_properties(test_freedv_api_700D_burble PROPERTIES PASS_REGULAR_EXPRESSION "output speech samples: 0")
+
+ add_test(NAME test_freedv_api_700D_AWGN_BER
+ COMMAND sh -c "dd bs=2560 count=120 if=/dev/zero | $ 700D - - --testframes | $ - - --No -20 -f -10 | $ 700D - /dev/null --testframes --discard"
+ )
+
+ # exercises complex rx codepath, albeit with just real samples
+ add_test(NAME test_freedv_api_700D_AWGN_BER_USECOMPLEX
+ COMMAND sh -c "dd bs=2560 count=120 if=/dev/zero | $ 700D - - --testframes | $ - - --No -20 -f -10 | $ 700D - /dev/null --testframes --discard --usecomplex"
+ )
+
+ # check real part of freedv_comptx() matches freedv_tx()
+ add_test(NAME test_freedv_api_700D_real_comp
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/demo:${CMAKE_CURRENT_BINARY_DIR}/unittest;
+ ./check_real_comp.sh"
+ )
+
+ # exercises freedv_comptx()
+ add_test(NAME test_freedv_api_700D_comptx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/unittest;
+ cat ${CMAKE_CURRENT_SOURCE_DIR}/raw/ve9qrp_10s.raw |
+ ./freedv_700d_comptx |
+ ./freedv_700d_comprx tx > /dev/null"
+ )
+
+ # exercises freedv_comprx()
+ add_test(NAME test_freedv_api_700D_comprx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/unittest;
+ cat ${CMAKE_CURRENT_SOURCE_DIR}/raw/ve9qrp_10s.raw |
+ ./freedv_700d_comptx |
+ ./freedv_700d_comprx rx > /dev/null"
+ )
+
+if(LPCNET)
+
+ add_test(NAME test_freedv_api_2020_to_ofdm_demod
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2020 ../../wav/wia_16kHz.wav - --testframes |
+ ./ofdm_demod --mode 2020 --verbose 1 --ldpc --testframes > /dev/null"
+ )
+
+ add_test(NAME test_freedv_api_2020_from_ofdm_mod
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./ofdm_mod --in /dev/zero --mode 2020 --verbose 1 --ldpc --testframes 10 |
+ ./freedv_rx 2020 - /dev/null --testframes"
+ )
+
+ add_test(NAME test_freedv_api_2020_awgn
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ dd bs=32000 count=10 if=/dev/zero |
+ ./freedv_tx 2020 - - --testframes |
+ ./ch - - --No -24 |
+ ./freedv_rx 2020 - /dev/null --testframes"
+ )
+
+ add_test(NAME test_freedv_api_2020B_mpp
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ dd bs=32000 count=60 if=/dev/zero |
+ ./freedv_tx 2020B - - --testframes --clip 1 |
+ ./ch - - --No -25 --mpp --fading_dir ../unittest |
+ ./freedv_rx 2020B - /dev/null --testframes"
+ )
+
+endif()
+
+ add_test(NAME test_freedv_api_2400A
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2400A ../../raw/ve9qrp_10s.raw - | ./freedv_rx 2400A - /dev/null")
+ set_tests_properties(test_freedv_api_2400A PROPERTIES PASS_REGULAR_EXPRESSION "frames decoded: 250")
+ add_test(NAME test_freedv_api_2400B
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2400B ../../raw/ve9qrp_10s.raw - | ./freedv_rx 2400B - /dev/null")
+ set_tests_properties(test_freedv_api_2400B PROPERTIES PASS_REGULAR_EXPRESSION "frames decoded: 250")
+ add_test(NAME test_freedv_api_800XA
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 800XA ../../raw/ve9qrp_10s.raw - | ./freedv_rx 800XA - /dev/null")
+ set_tests_properties(test_freedv_api_800XA PROPERTIES PASS_REGULAR_EXPRESSION "frames decoded: 125")
+
+ add_test(NAME test_freedv_api_rawdata_800XA
+ COMMAND sh -c "./tfreedv_800XA_rawdata"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/unittest
+ )
+
+ add_test(NAME test_freedv_api_rawdata_2400A
+ COMMAND sh -c "./tfreedv_2400A_rawdata"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/unittest
+ )
+
+ add_test(NAME test_freedv_api_rawdata_2400B
+ COMMAND sh -c "./tfreedv_2400B_rawdata"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/unittest
+ )
+
+ add_test(NAME test_peak_levels
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./check_peak.sh")
+ set_tests_properties(test_peak_levels PROPERTIES FAIL_REGULAR_EXPRESSION "FAIL")
+if(LPCNET)
+ add_test(NAME test_peak_levels_lpcnet
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./check_peak.sh LPCNet")
+ set_tests_properties(test_peak_levels_lpcnet PROPERTIES FAIL_REGULAR_EXPRESSION "FAIL")
+endif()
+
+ # -------------------------------------------------------------------------
+ # Reliable Text
+ # -------------------------------------------------------------------------
+ add_test(NAME test_freedv_reliable_text_truncate_string
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 1600 ../../raw/ve9qrp.raw - --reliabletext AB1CDEFGH > 1600_reliable.raw 2>/dev/null;
+ ./freedv_rx 1600 1600_reliable.raw /dev/null --txtrx 1600_reliable.txt --reliabletext 2>/dev/null;
+ grep 'AB1CDEFG' 1600_reliable.txt | wc -l")
+ set_tests_properties(test_freedv_reliable_text_truncate_string PROPERTIES PASS_REGULAR_EXPRESSION "20")
+
+ add_test(NAME test_freedv_reliable_text_ideal_1600
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 1600 ../../raw/ve9qrp.raw - --reliabletext AB1CDEF > 1600_reliable.raw 2>/dev/null;
+ ./freedv_rx 1600 1600_reliable.raw /dev/null --txtrx 1600_reliable.txt --reliabletext 2>/dev/null;
+ cat 1600_reliable.txt | wc -l")
+ set_tests_properties(test_freedv_reliable_text_ideal_1600 PROPERTIES PASS_REGULAR_EXPRESSION "20")
+
+ add_test(NAME test_freedv_reliable_text_ideal_700D
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700D ../../raw/ve9qrp.raw - --reliabletext AB1CDEF --txbpf 1 --clip 1 > 700D_reliable.raw 2>/dev/null;
+ ./freedv_rx 700D 700D_reliable.raw /dev/null --txtrx 700D_reliable.txt --reliabletext 2>/dev/null;
+ cat 700D_reliable.txt | wc -l")
+ set_tests_properties(test_freedv_reliable_text_ideal_700D PROPERTIES PASS_REGULAR_EXPRESSION "21")
+
+ add_test(NAME test_freedv_reliable_text_ideal_700E
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700E ../../raw/ve9qrp.raw - --reliabletext AB1CDEF --txbpf 1 --clip 1 > 700E_reliable.raw 2>/dev/null;
+ ./freedv_rx 700E 700E_reliable.raw /dev/null --txtrx 700E_reliable.txt --reliabletext 2>/dev/null;
+ cat 700E_reliable.txt | wc -l")
+ set_tests_properties(test_freedv_reliable_text_ideal_700E PROPERTIES PASS_REGULAR_EXPRESSION "21")
+
+ add_test(NAME test_freedv_reliable_text_awgn_1600
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 1600 ../../raw/ve9qrp.raw - --reliabletext AB1CDEF | ./ch - - --No -25 -f -5 > 1600_reliable.raw 2>/dev/null;
+ ./freedv_rx 1600 1600_reliable.raw /dev/null --txtrx 1600_reliable.txt --reliabletext 2>/dev/null;
+ if [ `cat 1600_reliable.txt | wc -l` -ge 10 ]; then echo 1; fi")
+ set_tests_properties(test_freedv_reliable_text_awgn_1600 PROPERTIES PASS_REGULAR_EXPRESSION "1")
+
+ add_test(NAME test_freedv_reliable_text_awgn_700D
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700D ../../raw/ve9qrp.raw - --reliabletext AB1CDEF --txbpf 1 --clip 1 | ./ch - - --No -12 -f -5 > 700D_reliable.raw 2>/dev/null;
+ ./freedv_rx 700D 700D_reliable.raw /dev/null --txtrx 700D_reliable.txt --reliabletext 2>/dev/null;
+ if [ `cat 700D_reliable.txt | wc -l` -ge 10 ]; then echo 1; fi")
+ set_tests_properties(test_freedv_reliable_text_awgn_700D PROPERTIES PASS_REGULAR_EXPRESSION "1")
+
+ add_test(NAME test_freedv_reliable_text_awgn_700E
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700E ../../raw/ve9qrp.raw - --reliabletext AB1CDEF --txbpf 1 --clip 1 | ./ch - - --No -15 -f -5 > 700E_reliable.raw 2>/dev/null;
+ ./freedv_rx 700E 700E_reliable.raw /dev/null --txtrx 700E_reliable.txt --reliabletext 2>/dev/null;
+ if [ `cat 700E_reliable.txt | wc -l` -ge 10 ]; then echo 1; fi")
+ set_tests_properties(test_freedv_reliable_text_awgn_700E PROPERTIES PASS_REGULAR_EXPRESSION "1")
+
+ add_test(NAME test_freedv_reliable_text_fade_1600
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest; ./reliable_text_fade.sh 1600 -28 3 0 '${CMAKE_CURRENT_BINARY_DIR}/src'")
+
+ add_test(NAME test_freedv_reliable_text_fade_700D
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest; ./reliable_text_fade.sh 700D -19 8 1 '${CMAKE_CURRENT_BINARY_DIR}/src'")
+
+ add_test(NAME test_freedv_reliable_text_fade_700E
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest; ./reliable_text_fade.sh 700E -22 8 1 '${CMAKE_CURRENT_BINARY_DIR}/src'")
+
+if(LPCNET)
+ add_test(NAME test_freedv_reliable_text_ideal_2020
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2020 ../../raw/ve9qrp.raw - --reliabletext AB1CDEF > 2020_reliable.raw 2>/dev/null;
+ ./freedv_rx 2020 2020_reliable.raw /dev/null --txtrx 2020_reliable.txt --reliabletext 2>/dev/null;
+ cat 2020_reliable.txt | wc -l")
+ set_tests_properties(test_freedv_reliable_text_ideal_2020 PROPERTIES PASS_REGULAR_EXPRESSION "9")
+
+ add_test(NAME test_freedv_reliable_text_awgn_2020
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2020 ../../raw/ve9qrp.raw - --reliabletext AB1CDEF | ./ch - - --No -22 -f -5 > 2020_reliable.raw 2>/dev/null;
+ ./freedv_rx 2020 2020_reliable.raw /dev/null --txtrx 2020_reliable.txt --reliabletext 2>/dev/null;
+ if [ `cat 2020_reliable.txt | wc -l` -ge 9 ]; then echo 1; fi")
+ set_tests_properties(test_freedv_reliable_text_awgn_1600 PROPERTIES PASS_REGULAR_EXPRESSION "1")
+
+ add_test(NAME test_freedv_reliable_text_fade_2020
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest; ./reliable_text_fade.sh 2020 -26 4 0 '${CMAKE_CURRENT_BINARY_DIR}/src'")
+endif(LPCNET)
+
+ # -------------------------------------------------------------------------
+ # FreeDV API memory leaks
+ # -------------------------------------------------------------------------
+
+if (NOT APPLE)
+ add_test(NAME test_memory_leak_FreeDV_1600_tx
+ COMMAND sh -c " valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./freedv_tx 1600 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw /dev/null"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ set_tests_properties(test_memory_leak_FreeDV_1600_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_1600_rx
+ COMMAND sh -c "./freedv_tx 1600 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw t.raw; \
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./freedv_rx 1600 t.raw /dev/null"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ set_tests_properties(test_memory_leak_FreeDV_1600_rx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_700D_tx
+ COMMAND sh -c " valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./freedv_tx 700D ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw /dev/null"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ set_tests_properties(test_memory_leak_FreeDV_700D_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_700D_rx
+ COMMAND sh -c "./freedv_tx 700D ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw t.raw; \
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./freedv_rx 700D t.raw /dev/null"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ set_tests_properties(test_memory_leak_FreeDV_700D_rx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_700C_tx
+ COMMAND sh -c " valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./freedv_tx 700C ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw /dev/null"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ set_tests_properties(test_memory_leak_FreeDV_700C_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_700C_rx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 700C ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw t.raw; \
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./freedv_rx 700C t.raw /dev/null"
+ )
+ set_tests_properties(test_memory_leak_FreeDV_700C_rx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_FSK_LDPC_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 FSK_LDPC /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_FSK_LDPC_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_DATAC0_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 DATAC0 /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_DATAC0_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_DATAC1_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 DATAC1 /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_DATAC1_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_DATAC3_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 DATAC3 /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_DATAC3_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_DATAC4_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 DATAC4 /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_DATAC4_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_DATAC13_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 DATAC13 /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_DATAC13_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_DATAC14_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_data_raw_tx --testframes 10 DATAC14 /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_DATAC14_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_700E_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_tx --testframes 10 700E /dev/zero /dev/null")
+ set_tests_properties(test_memory_leak_FreeDV_700E_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+if(LPCNET)
+ add_test(NAME test_memory_leak_FreeDV_2020_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_tx 2020 ../../wav/wia_16kHz.wav /dev/null"
+ )
+ set_tests_properties(test_memory_leak_FreeDV_2020_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_2020_rx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2020 ../../wav/wia_16kHz.wav t.raw; \
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_rx 2020 t.raw /dev/null"
+ )
+ set_tests_properties(test_memory_leak_FreeDV_2020_rx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_2020B_tx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_tx 2020B ../../wav/wia_16kHz.wav /dev/null"
+ )
+ set_tests_properties(test_memory_leak_FreeDV_2020B_tx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+
+ add_test(NAME test_memory_leak_FreeDV_2020B_rx
+ COMMAND sh -c "cd ${CMAKE_CURRENT_BINARY_DIR}/src;
+ ./freedv_tx 2020B ../../wav/wia_16kHz.wav t.raw; \
+ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
+ ./freedv_rx 2020B t.raw /dev/null"
+ )
+ set_tests_properties(test_memory_leak_FreeDV_2020B_rx PROPERTIES PASS_REGULAR_EXPRESSION "ERROR SUMMARY: 0 errors")
+endif(LPCNET)
+endif(NOT APPLE)
+
+ # -------------------------------------------------------------------------
+ # Codec 2 modes
+ # -------------------------------------------------------------------------
+
+ add_test(NAME test_codec2_mode_dot_c2
+ COMMAND sh -c "./c2enc 700C ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw hts1a.c2 && ./c2dec 1600 hts1a.c2 /dev/null"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ set_tests_properties(test_codec2_mode_dot_c2 PROPERTIES PASS_REGULAR_EXPRESSION "mode 8")
+
+ add_test(NAME test_codec2_mode_3200
+ COMMAND sh -c "./c2enc 3200 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw - | ./c2dec 3200 - - | sox -t .s16 -r 8000 - hts1a_3200.wav"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ add_test(NAME test_codec2_mode_2400
+ COMMAND sh -c "./c2enc 2400 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw - | ./c2dec 2400 - - | sox -t .s16 -r 8000 - hts1a_2400.wav"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ add_test(NAME test_codec2_mode_1400
+ COMMAND sh -c "./c2enc 1400 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw - | ./c2dec 1400 - - | sox -t .s16 -r 8000 - hts1a_1400.wav"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ add_test(NAME test_codec2_mode_1300
+ COMMAND sh -c "./c2enc 1300 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw - | ./c2dec 1300 - - | sox -t .s16 -r 8000 - hts1a_1300.wav"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ add_test(NAME test_codec2_mode_1200
+ COMMAND sh -c "./c2enc 1200 ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw - | ./c2dec 1200 - - | sox -t .s16 -r 8000 - hts1a_1200.wav"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+ add_test(NAME test_codec2_mode_700C
+ COMMAND sh -c "./c2enc 700C ${CMAKE_CURRENT_SOURCE_DIR}/raw/hts1a.raw - | ./c2dec 700C - - | sox -t .s16 -r 8000 - hts1a_700C.wav"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/src
+ )
+
+ add_test(NAME test_vq_mbest
+ COMMAND sh -c "./tvq_mbest; \
+ cat target.f32 | \
+ ./vq_mbest -k 4 -q vq1.f32,vq2.f32 --st 1 --en 2 --mbest 2 -v > /dev/null;"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/unittest
+ )
+ set_tests_properties(test_vq_mbest PROPERTIES PASS_REGULAR_EXPRESSION "MSE: 0.00")
+
+ add_test(NAME test_700c_eq
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/unittest;
+ PATH=$PATH:${CMAKE_CURRENT_BINARY_DIR}/src ./test_700c_eq.sh")
+
+ # -------------------------------------------------------------------------
+ # FSK Modem
+ # -------------------------------------------------------------------------
+
+ # Octave FSK Modem, to make sure we don't break reference simulation
+ add_test(NAME test_fsk_lib
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave; DISPLAY=\"\" octave-cli -qf fsk_lib_demo.m")
+ set_tests_properties(test_fsk_lib PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ add_test(NAME test_fsk_modem_octave_port
+ COMMAND sh -c "cd ${CMAKE_CURRENT_SOURCE_DIR}/octave;
+ PATH_TO_TFSK=${CMAKE_CURRENT_BINARY_DIR}/unittest/tfsk octave-cli -qf tfsk.m")
+ set_tests_properties(test_fsk_modem_octave_port PROPERTIES PASS_REGULAR_EXPRESSION "PASS")
+
+ add_test(NAME test_fsk_modem_mod_demod
+ COMMAND sh -c "$