mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Add LPCNet voice mode option
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -55,6 +56,7 @@ class _RetainedRoute {
|
|||||||
|
|
||||||
/// Contacts Provider - manages contact list and telemetry
|
/// Contacts Provider - manages contact list and telemetry
|
||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
@@ -367,13 +369,36 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
incomingContact: incomingContact,
|
incomingContact: incomingContact,
|
||||||
existingContact: existingContact,
|
existingContact: existingContact,
|
||||||
);
|
);
|
||||||
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: existingContact?.telemetry,
|
||||||
|
incomingTelemetry: incomingContact.telemetry,
|
||||||
|
);
|
||||||
|
final inferredFallbackLocation = _inferFirstHopFallbackLocation(
|
||||||
|
incomingContact: incomingContact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
retainedRoute: retainedRoute,
|
||||||
|
mergedTelemetry: mergedTelemetry,
|
||||||
|
);
|
||||||
|
final inferredFallbackAdvLat = inferredFallbackLocation != null
|
||||||
|
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude)
|
||||||
|
: null;
|
||||||
|
final inferredFallbackAdvLon = inferredFallbackLocation != null
|
||||||
|
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (existingContact == null) {
|
if (existingContact == null) {
|
||||||
var newContact = incomingContact.copyWith(
|
var newContact = incomingContact.copyWith(
|
||||||
isNew: true,
|
isNew: true,
|
||||||
|
telemetry: mergedTelemetry,
|
||||||
outPathLen:
|
outPathLen:
|
||||||
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||||||
|
advLat: incomingContact.advertLocation != null
|
||||||
|
? incomingContact.advLat
|
||||||
|
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||||||
|
advLon: incomingContact.advertLocation != null
|
||||||
|
? incomingContact.advLon
|
||||||
|
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||||||
);
|
);
|
||||||
if (incomingContact.advertLocation != null) {
|
if (incomingContact.advertLocation != null) {
|
||||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||||
@@ -387,10 +412,6 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return newContact;
|
return newContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mergedTelemetry = _mergeTelemetryForContact(
|
|
||||||
existingTelemetry: existingContact.telemetry,
|
|
||||||
incomingTelemetry: incomingContact.telemetry,
|
|
||||||
);
|
|
||||||
final incomingAdvertLocation = incomingContact.advertLocation;
|
final incomingAdvertLocation = incomingContact.advertLocation;
|
||||||
final existingAdvertLocation = existingContact.advertLocation;
|
final existingAdvertLocation = existingContact.advertLocation;
|
||||||
|
|
||||||
@@ -405,12 +426,12 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
? incomingContact.advLat
|
? incomingContact.advLat
|
||||||
: existingAdvertLocation != null
|
: existingAdvertLocation != null
|
||||||
? existingContact.advLat
|
? existingContact.advLat
|
||||||
: incomingContact.advLat,
|
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||||||
advLon: incomingAdvertLocation != null
|
advLon: incomingAdvertLocation != null
|
||||||
? incomingContact.advLon
|
? incomingContact.advLon
|
||||||
: existingAdvertLocation != null
|
: existingAdvertLocation != null
|
||||||
? existingContact.advLon
|
? existingContact.advLon
|
||||||
: incomingContact.advLon,
|
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (incomingAdvertLocation != null) {
|
if (incomingAdvertLocation != null) {
|
||||||
@@ -457,6 +478,114 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LatLng? _inferFirstHopFallbackLocation({
|
||||||
|
required Contact incomingContact,
|
||||||
|
required Contact? existingContact,
|
||||||
|
required _RetainedRoute? retainedRoute,
|
||||||
|
required ContactTelemetry? mergedTelemetry,
|
||||||
|
}) {
|
||||||
|
if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (incomingContact.advertLocation != null ||
|
||||||
|
existingContact?.advertLocation != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final routeBytes =
|
||||||
|
retainedRoute?.paddedPathBytes ??
|
||||||
|
(incomingContact.routeHasPath
|
||||||
|
? incomingContact.routePathBytes
|
||||||
|
: existingContact?.routeHasPath == true
|
||||||
|
? existingContact!.routePathBytes
|
||||||
|
: null);
|
||||||
|
final routeHashSize = retainedRoute != null
|
||||||
|
? ((ContactRouteCodec.toUnsignedDescriptor(
|
||||||
|
retainedRoute.signedEncodedPathLen,
|
||||||
|
) >>
|
||||||
|
6) +
|
||||||
|
1)
|
||||||
|
: incomingContact.routeHasPath
|
||||||
|
? incomingContact.routeHashSize
|
||||||
|
: existingContact?.routeHasPath == true
|
||||||
|
? existingContact!.routeHashSize
|
||||||
|
: 0;
|
||||||
|
if (routeBytes == null ||
|
||||||
|
routeHashSize <= 0 ||
|
||||||
|
routeBytes.length < routeHashSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final firstHopHex = routeBytes
|
||||||
|
.sublist(0, routeHashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final repeaterCandidates = _contacts.values.where((candidate) {
|
||||||
|
if (!candidate.isRepeater ||
|
||||||
|
candidate.publicKeyHex == incomingContact.publicKeyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final location = candidate.displayLocation;
|
||||||
|
return location != null && candidate.publicKeyHex.startsWith(firstHopHex);
|
||||||
|
}).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
|
||||||
|
if (repeaterCandidates.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final repeaterLocation = repeaterCandidates.first.displayLocation;
|
||||||
|
if (repeaterLocation == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bearingDegrees = _stableFallbackBearingDegrees(incomingContact);
|
||||||
|
return _offsetFromLocation(
|
||||||
|
repeaterLocation,
|
||||||
|
distanceMeters: _firstHopFallbackOffsetMeters,
|
||||||
|
bearingDegrees: bearingDegrees,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _stableFallbackBearingDegrees(Contact contact) {
|
||||||
|
if (contact.publicKey.length < 2) {
|
||||||
|
return 90.0;
|
||||||
|
}
|
||||||
|
final seed = (contact.publicKey[0] << 8) | contact.publicKey[1];
|
||||||
|
return (seed % 360).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng _offsetFromLocation(
|
||||||
|
LatLng origin, {
|
||||||
|
required double distanceMeters,
|
||||||
|
required double bearingDegrees,
|
||||||
|
}) {
|
||||||
|
const earthRadiusMeters = 6371000.0;
|
||||||
|
final angularDistance = distanceMeters / earthRadiusMeters;
|
||||||
|
final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0;
|
||||||
|
final lat1 = origin.latitude * 3.1415926535897932 / 180.0;
|
||||||
|
final lon1 = origin.longitude * 3.1415926535897932 / 180.0;
|
||||||
|
|
||||||
|
final sinLat1 = sin(lat1);
|
||||||
|
final cosLat1 = cos(lat1);
|
||||||
|
final sinAngularDistance = sin(angularDistance);
|
||||||
|
final cosAngularDistance = cos(angularDistance);
|
||||||
|
|
||||||
|
final lat2 = asin(
|
||||||
|
sinLat1 * cosAngularDistance +
|
||||||
|
cosLat1 * sinAngularDistance * cos(bearingRadians),
|
||||||
|
);
|
||||||
|
final lon2 =
|
||||||
|
lon1 +
|
||||||
|
atan2(
|
||||||
|
sin(bearingRadians) * sinAngularDistance * cosLat1,
|
||||||
|
cosAngularDistance - sinLat1 * sin(lat2),
|
||||||
|
);
|
||||||
|
|
||||||
|
return LatLng(
|
||||||
|
lat2 * 180.0 / 3.1415926535897932,
|
||||||
|
((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Update contact telemetry
|
/// Update contact telemetry
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class MessageStorageService {
|
|||||||
'stored_message_transfer_details';
|
'stored_message_transfer_details';
|
||||||
static const String _messageRouteMetadataKey =
|
static const String _messageRouteMetadataKey =
|
||||||
'stored_message_route_metadata';
|
'stored_message_route_metadata';
|
||||||
|
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||||
|
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
@@ -32,8 +34,16 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
// survive restore even if the sidecar reception-details entry is absent.
|
||||||
|
final jsonList = messages
|
||||||
|
.map(
|
||||||
|
(msg) => _messageToJson(
|
||||||
|
msg,
|
||||||
|
receptionDetails: messageReceptionDetails[msg.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Limit to max stored messages (keep most recent)
|
// Limit to max stored messages (keep most recent)
|
||||||
final limitedList = jsonList.length > _maxStoredMessages
|
final limitedList = jsonList.length > _maxStoredMessages
|
||||||
@@ -129,24 +139,36 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(jsonString);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = <String, MessageReceptionDetails>{};
|
final result = <String, MessageReceptionDetails>{};
|
||||||
for (final entry in decoded.entries) {
|
if (jsonString != null && jsonString.isNotEmpty) {
|
||||||
final value = entry.value;
|
final decoded = jsonDecode(jsonString);
|
||||||
if (value is! Map<String, dynamic>) continue;
|
if (decoded is Map<String, dynamic>) {
|
||||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
for (final entry in decoded.entries) {
|
||||||
if (snapshot != null) {
|
final value = entry.value;
|
||||||
result[entry.key] = snapshot;
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||||
|
if (snapshot != null) {
|
||||||
|
result[entry.key] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
|
||||||
|
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||||
|
result.putIfAbsent(messageId, () => snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
|
||||||
|
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||||
|
result.putIfAbsent(
|
||||||
|
messageId,
|
||||||
|
() => MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||||
@@ -278,7 +300,10 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Message to JSON
|
/// Convert Message to JSON
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
Map<String, dynamic> _messageToJson(
|
||||||
|
Message message, {
|
||||||
|
MessageReceptionDetails? receptionDetails,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -338,9 +363,67 @@ class MessageStorageService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
if (receptionDetails != null)
|
||||||
|
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||||
|
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||||
|
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageReceptionDetails>>
|
||||||
|
_loadEmbeddedReceptionDetails() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageReceptionDetails>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||||
|
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||||
|
if (snapshot == null) continue;
|
||||||
|
result[messageId] = snapshot;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, List<int>>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final pathBytes = entry[_legacyPathBytesKey];
|
||||||
|
if (messageId is! String || pathBytes is! List) continue;
|
||||||
|
final normalized = pathBytes
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList();
|
||||||
|
if (normalized.isEmpty) continue;
|
||||||
|
result[messageId] = normalized;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert JSON to Message
|
/// Convert JSON to Message
|
||||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
@@ -522,6 +523,58 @@ void main() {
|
|||||||
isEmpty,
|
isEmpty,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('infers a fallback location 100m from first-hop repeater', () {
|
||||||
|
final repeaterKey = Uint8List.fromList([
|
||||||
|
0xAA,
|
||||||
|
0xBB,
|
||||||
|
0x10,
|
||||||
|
0x11,
|
||||||
|
0x12,
|
||||||
|
0x13,
|
||||||
|
...List<int>.generate(26, (index) => index + 20),
|
||||||
|
]);
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: repeaterKey,
|
||||||
|
type: ContactType.repeater,
|
||||||
|
name: 'Relay Alpha',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final targetKey = createPublicKey(120);
|
||||||
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: targetKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: route.signedEncodedPathLen,
|
||||||
|
outPath: route.paddedPathBytes,
|
||||||
|
advName: 'No GPS',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(targetKey)!;
|
||||||
|
final inferred = updated.displayLocation;
|
||||||
|
final repeater = provider.findContactByKey(repeaterKey)!;
|
||||||
|
final repeaterLocation = repeater.displayLocation;
|
||||||
|
|
||||||
|
expect(inferred, isNotNull);
|
||||||
|
expect(repeaterLocation, isNotNull);
|
||||||
|
|
||||||
|
final distanceMeters = Geolocator.distanceBetween(
|
||||||
|
repeaterLocation!.latitude,
|
||||||
|
repeaterLocation.longitude,
|
||||||
|
inferred!.latitude,
|
||||||
|
inferred.longitude,
|
||||||
|
);
|
||||||
|
expect(distanceMeters, closeTo(100.0, 8.0));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider.updateFastGps', () {
|
group('ContactsProvider.updateFastGps', () {
|
||||||
|
|||||||
101
test/services/message_storage_service_test.dart
Normal file
101
test/services/message_storage_service_test.dart
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/message_storage_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retains path bytes for stored unread message when reception sidecar is missing',
|
||||||
|
() async {
|
||||||
|
final storage = MessageStorageService();
|
||||||
|
final message = Message(
|
||||||
|
id: 'msg-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||||
|
pathLen: 2,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Unread message',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
isRead: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.saveMessages(
|
||||||
|
[message],
|
||||||
|
messageReceptionDetails: {
|
||||||
|
message.id: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
|
||||||
|
pathBytes: const [0xAA, 0xBB, 0xCC],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('stored_message_reception_details');
|
||||||
|
|
||||||
|
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||||
|
|
||||||
|
expect(restoredDetails.keys, contains(message.id));
|
||||||
|
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('retains embedded reception details when sidecar is missing', () async {
|
||||||
|
final storage = MessageStorageService();
|
||||||
|
final message = Message(
|
||||||
|
id: 'msg-2',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
|
||||||
|
channelIdx: 2,
|
||||||
|
pathLen: 3,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000100,
|
||||||
|
text: 'Room update',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
|
||||||
|
isRead: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.saveMessages(
|
||||||
|
[message],
|
||||||
|
messageReceptionDetails: {
|
||||||
|
message.id: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
|
||||||
|
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
|
rssiDbm: -91,
|
||||||
|
snrDb: 7.25,
|
||||||
|
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
|
||||||
|
senderToReceiptMs: 1200,
|
||||||
|
estimatedTransmitMs: 800,
|
||||||
|
postTransmitDelayMs: 400,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('stored_message_reception_details');
|
||||||
|
|
||||||
|
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||||
|
final restored = restoredDetails[message.id];
|
||||||
|
|
||||||
|
expect(restored, isNotNull);
|
||||||
|
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
expect(restored.rssiDbm, -91);
|
||||||
|
expect(restored.snrDb, 7.25);
|
||||||
|
expect(restored.senderToReceiptMs, 1200);
|
||||||
|
expect(restored.estimatedTransmitMs, 800);
|
||||||
|
expect(restored.postTransmitDelayMs, 400);
|
||||||
|
expect(
|
||||||
|
restored.packetLoggedAt,
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user