mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Improve image zoom quality
This commit is contained in:
@@ -7,12 +7,14 @@ import 'messages_provider.dart';
|
||||
import 'drawing_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'voice_provider.dart';
|
||||
import 'image_provider.dart' as ip;
|
||||
import '../services/tile_cache_service.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
|
||||
/// Main App Provider - coordinates all other providers
|
||||
class AppProvider with ChangeNotifier {
|
||||
@@ -22,6 +24,7 @@ class AppProvider with ChangeNotifier {
|
||||
final DrawingProvider drawingProvider;
|
||||
final ChannelsProvider channelsProvider;
|
||||
final VoiceProvider voiceProvider;
|
||||
final ip.ImageProvider imageProvider;
|
||||
final TileCacheService tileCacheService;
|
||||
final LocationTrackingService locationTrackingService =
|
||||
LocationTrackingService();
|
||||
@@ -39,6 +42,10 @@ class AppProvider with ChangeNotifier {
|
||||
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||
bool _isVoiceBandPassFilterEnabled = true;
|
||||
bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled;
|
||||
bool _isVoiceCompressorEnabled = true;
|
||||
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
|
||||
bool _isVoiceLimiterEnabled = true;
|
||||
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
||||
|
||||
AppProvider({
|
||||
required this.connectionProvider,
|
||||
@@ -47,6 +54,7 @@ class AppProvider with ChangeNotifier {
|
||||
required this.drawingProvider,
|
||||
required this.channelsProvider,
|
||||
required this.voiceProvider,
|
||||
required this.imageProvider,
|
||||
required this.tileCacheService,
|
||||
}) {
|
||||
_setupCallbacks();
|
||||
@@ -56,6 +64,8 @@ class AppProvider with ChangeNotifier {
|
||||
_loadMapEnabled();
|
||||
_loadVoiceSilenceTrimmingEnabled();
|
||||
_loadVoiceBandPassFilterEnabled();
|
||||
_loadVoiceCompressorEnabled();
|
||||
_loadVoiceLimiterEnabled();
|
||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||
_isInitialized = true;
|
||||
}
|
||||
@@ -173,6 +183,53 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load voice compressor setting from shared preferences.
|
||||
Future<void> _loadVoiceCompressorEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceCompressorEnabled =
|
||||
prefs.getBool('voice_compressor_enabled') ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice compressor setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle voice compressor on/off.
|
||||
Future<void> toggleVoiceCompressorEnabled(bool enabled) async {
|
||||
try {
|
||||
_isVoiceCompressorEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_compressor_enabled', enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice compressor setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load voice limiter setting from shared preferences.
|
||||
Future<void> _loadVoiceLimiterEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceLimiterEnabled = prefs.getBool('voice_limiter_enabled') ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice limiter setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle voice limiter on/off.
|
||||
Future<void> toggleVoiceLimiterEnabled(bool enabled) async {
|
||||
try {
|
||||
_isVoiceLimiterEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_limiter_enabled', enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice limiter setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize tile cache service
|
||||
Future<void> _initializeTileCache() async {
|
||||
try {
|
||||
@@ -234,6 +291,20 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
};
|
||||
|
||||
// Image raw-packet serving reuses the same BLE raw-data path as voice.
|
||||
imageProvider.sendRawPacketCallback =
|
||||
({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
}) async {
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: contactPath,
|
||||
contactPathLen: contactPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
};
|
||||
|
||||
// When a contact is received from BLE
|
||||
connectionProvider.onContactReceived = (contact) {
|
||||
// Pass device public key to filter out our own contact
|
||||
@@ -472,6 +543,57 @@ class AppProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Image fetch request (IR1): requester asks us to stream image fragments.
|
||||
final imageFetchRequest = ImageFetchRequest.tryParse(enrichedMessage.text);
|
||||
if (imageFetchRequest != null) {
|
||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null) {
|
||||
final senderPrefixHex = senderPrefix
|
||||
.take(6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
if (senderPrefixHex.toLowerCase() ==
|
||||
imageFetchRequest.requesterKey6.toLowerCase()) {
|
||||
final requester = contactsProvider.findContactByPrefix(
|
||||
senderPrefix,
|
||||
);
|
||||
if (requester != null) {
|
||||
unawaited(
|
||||
imageProvider.serveSessionTo(
|
||||
sessionId: imageFetchRequest.sessionId,
|
||||
requester: requester,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return; // IR1 is control-plane only; not displayed in chat
|
||||
}
|
||||
|
||||
// Image envelope (IE1): announce image availability.
|
||||
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
||||
if (imageEnvelope != null) {
|
||||
imageProvider.registerEnvelope(imageEnvelope);
|
||||
messagesProvider.addMessage(
|
||||
enrichedMessage,
|
||||
contactLookup: (name) {
|
||||
try {
|
||||
final contact = contactsProvider.contacts.firstWhere(
|
||||
(c) => c.advName == name,
|
||||
);
|
||||
return contact.publicKeyHex.isNotEmpty &&
|
||||
contact.publicKeyHex.length >= 12
|
||||
? contact.publicKeyHex.substring(0, 12)
|
||||
: '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a text-format voice packet, feed it to VoiceProvider
|
||||
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
|
||||
@@ -531,8 +653,21 @@ class AppProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
||||
// Used for direct binary voice packets (VoicePacket binary format, magic 0x56 'V')
|
||||
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||
if (ImagePacket.isImageBinary(payload)) {
|
||||
final frag = ImagePacket.tryParseBinary(payload);
|
||||
if (frag == null) return;
|
||||
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
|
||||
final session = imageProvider.session(frag.sessionId);
|
||||
imageProvider.addFragment(
|
||||
frag,
|
||||
width: session?.width ?? 0,
|
||||
height: session?.height ?? 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VoicePacket.isVoiceBinary(payload)) return;
|
||||
final pkt = VoicePacket.tryParseBinary(payload);
|
||||
if (pkt == null) return;
|
||||
@@ -984,6 +1119,7 @@ class AppProvider with ChangeNotifier {
|
||||
contactsProvider.clearContacts();
|
||||
messagesProvider.clearAll();
|
||||
unawaited(voiceProvider.clearStoredVoiceData());
|
||||
unawaited(imageProvider.clearAll());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
360
lib/providers/image_provider.dart
Normal file
360
lib/providers/image_provider.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
|
||||
/// Reassembly state for one incoming image session.
|
||||
class ImageSession {
|
||||
final String sessionId;
|
||||
final ImageFormat format;
|
||||
final int total;
|
||||
final int width;
|
||||
final int height;
|
||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||
|
||||
ImageSession({
|
||||
required this.sessionId,
|
||||
required this.format,
|
||||
required this.total,
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : fragments = List.filled(total, null);
|
||||
|
||||
int get receivedCount => fragments.where((f) => f != null).length;
|
||||
bool get isComplete => receivedCount == total;
|
||||
|
||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||
}
|
||||
|
||||
/// Manages incoming image sessions and outgoing image caches.
|
||||
///
|
||||
/// Mirrors [VoiceProvider] in architecture: on-demand fetch, deferred serving,
|
||||
/// persistent storage of both incoming and outgoing session data.
|
||||
class ImageProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'stored_image_sessions_v1';
|
||||
static const Duration _outgoingTtl = Duration(minutes: 15);
|
||||
|
||||
/// Incoming sessions keyed by sessionId.
|
||||
final Map<String, ImageSession> _sessions = {};
|
||||
|
||||
/// Outgoing sessions cached for deferred serving.
|
||||
final Map<String, _OutgoingSession> _outgoing = {};
|
||||
|
||||
/// Hook for sending a raw binary payload to a contact.
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
|
||||
ImageProvider() {
|
||||
_restore();
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
ImageSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||
|
||||
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||
|
||||
/// Add a received [fragment]. Creates the session on first fragment using
|
||||
/// metadata from the fragment itself (requires envelope to have been
|
||||
/// announced first; if not, defaults width/height to 0 — corrected on save).
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
_sessions.putIfAbsent(
|
||||
fragment.sessionId,
|
||||
() => ImageSession(
|
||||
sessionId: fragment.sessionId,
|
||||
format: fragment.format,
|
||||
total: fragment.total,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
|
||||
final session = _sessions[fragment.sessionId]!;
|
||||
if (fragment.index < session.total) {
|
||||
session.fragments[fragment.index] = fragment;
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
/// Register envelope metadata for a session (called when IE1 is received
|
||||
/// before any binary fragments arrive).
|
||||
void registerEnvelope(ImageEnvelope envelope) {
|
||||
_sessions.putIfAbsent(
|
||||
envelope.sessionId,
|
||||
() => ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
),
|
||||
);
|
||||
// Update dimensions if we created the session from a fragment (w/h = 0).
|
||||
final session = _sessions[envelope.sessionId]!;
|
||||
if (session.width == 0 || session.height == 0) {
|
||||
_sessions[envelope.sessionId] = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
// Copy existing fragments into the new session.
|
||||
final old = _sessions[envelope.sessionId]!;
|
||||
for (var i = 0; i < session.fragments.length && i < old.total; i++) {
|
||||
old.fragments[i] = session.fragments[i];
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Outgoing session management ──────────────────────────────────────────
|
||||
|
||||
/// Cache encoded fragments for deferred serving.
|
||||
///
|
||||
/// Also registers the session as complete in [_sessions] so the local
|
||||
/// bubble can display the sent image immediately without a fetch round-trip.
|
||||
void cacheOutgoingSession(
|
||||
String sessionId,
|
||||
List<ImagePacket> fragments,
|
||||
ImageEnvelope envelope,
|
||||
) {
|
||||
if (fragments.isEmpty) return;
|
||||
_evictExpiredOutgoing();
|
||||
_outgoing[sessionId] = _OutgoingSession(
|
||||
sessionId: sessionId,
|
||||
fragments: List<ImagePacket>.from(fragments),
|
||||
envelope: envelope,
|
||||
cachedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Populate incoming session so the bubble shows the image right away.
|
||||
final session = ImageSession(
|
||||
sessionId: sessionId,
|
||||
format: envelope.format,
|
||||
total: fragments.length,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
for (final f in fragments) {
|
||||
if (f.index < session.total) session.fragments[f.index] = f;
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stream cached image fragments to [requester] via raw binary packets.
|
||||
Future<bool> serveSessionTo({
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
}) async {
|
||||
final cached = _outgoing[sessionId];
|
||||
if (cached == null) {
|
||||
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
||||
return false;
|
||||
}
|
||||
if (sendRawPacketCallback == null) {
|
||||
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen < 0) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] ${requester.advName} has no direct path',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final fragment in cached.fragments) {
|
||||
try {
|
||||
await sendRawPacketCallback!(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
payload: fragment.encodeBinary(),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
debugPrint(
|
||||
'📷 [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_sessions.clear();
|
||||
_outgoing.clear();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _evictExpiredOutgoing() {
|
||||
final now = DateTime.now();
|
||||
_outgoing.removeWhere(
|
||||
(_, s) => now.difference(s.cachedAt) > _outgoingTtl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
_evictExpiredOutgoing();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final payload = <String, dynamic>{
|
||||
'incoming': _sessions.values
|
||||
.map(
|
||||
(s) => {
|
||||
'sessionId': s.sessionId,
|
||||
'fmtId': s.format.id,
|
||||
'total': s.total,
|
||||
'width': s.width,
|
||||
'height': s.height,
|
||||
'fragments': s.fragments
|
||||
.map(
|
||||
(f) => f == null
|
||||
? null
|
||||
: base64.encode(f.encodeBinary()),
|
||||
)
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'outgoing': _outgoing.values
|
||||
.map(
|
||||
(s) => {
|
||||
'sessionId': s.sessionId,
|
||||
'cachedAt': s.cachedAt.millisecondsSinceEpoch,
|
||||
'envelope': s.envelope.encode(),
|
||||
'fragments': s.fragments
|
||||
.map((f) => base64.encode(f.encodeBinary()))
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_storageKey, jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
|
||||
for (final item in (parsed['incoming'] as List<dynamic>? ?? [])) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final fmtId = map['fmtId'] as int?;
|
||||
final total = map['total'] as int?;
|
||||
final width = map['width'] as int? ?? 256;
|
||||
final height = map['height'] as int? ?? 256;
|
||||
if (sessionId == null || fmtId == null || total == null || total <= 0) {
|
||||
continue;
|
||||
}
|
||||
final session = ImageSession(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.fromId(fmtId),
|
||||
total: total,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
final frags = map['fragments'] as List<dynamic>? ?? [];
|
||||
for (var i = 0; i < frags.length && i < total; i++) {
|
||||
final enc = frags[i] as String?;
|
||||
if (enc == null || enc.isEmpty) continue;
|
||||
final pkt = ImagePacket.tryParseBinary(base64.decode(enc));
|
||||
if (pkt != null && pkt.index < total) {
|
||||
session.fragments[pkt.index] = pkt;
|
||||
}
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
for (final item in (parsed['outgoing'] as List<dynamic>? ?? [])) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final cachedMs = map['cachedAt'] as int?;
|
||||
final envelopeText = map['envelope'] as String?;
|
||||
if (sessionId == null || cachedMs == null || envelopeText == null) {
|
||||
continue;
|
||||
}
|
||||
final envelope = ImageEnvelope.tryParse(envelopeText);
|
||||
if (envelope == null) continue;
|
||||
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedMs);
|
||||
if (DateTime.now().difference(cachedAt) > _outgoingTtl) continue;
|
||||
|
||||
final fragsRaw = map['fragments'] as List<dynamic>? ?? [];
|
||||
final fragments = <ImagePacket>[];
|
||||
for (final enc in fragsRaw) {
|
||||
final pkt = ImagePacket.tryParseBinary(
|
||||
base64.decode((enc ?? '') as String),
|
||||
);
|
||||
if (pkt != null) fragments.add(pkt);
|
||||
}
|
||||
if (fragments.isNotEmpty) {
|
||||
_outgoing[sessionId] = _OutgoingSession(
|
||||
sessionId: sessionId,
|
||||
fragments: fragments,
|
||||
envelope: envelope,
|
||||
cachedAt: cachedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_sessions.isNotEmpty || _outgoing.isNotEmpty) {
|
||||
debugPrint(
|
||||
'📷 [ImageProvider] Restored ${_sessions.length} incoming, '
|
||||
'${_outgoing.length} outgoing sessions',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingSession {
|
||||
final String sessionId;
|
||||
final List<ImagePacket> fragments;
|
||||
final ImageEnvelope envelope;
|
||||
final DateTime cachedAt;
|
||||
|
||||
const _OutgoingSession({
|
||||
required this.sessionId,
|
||||
required this.fragments,
|
||||
required this.envelope,
|
||||
required this.cachedAt,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user