Improve image zoom quality

This commit is contained in:
Janez T
2026-03-01 19:17:09 +01:00
parent be7fe6c4b0
commit 0e7ba5572d
20 changed files with 2157 additions and 15 deletions

View File

@@ -12,6 +12,7 @@ import 'providers/map_provider.dart';
import 'providers/drawing_provider.dart';
import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart';
import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
@@ -242,6 +243,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
),
),
// Image provider (fragment reassembly + outgoing session cache)
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
@@ -263,6 +267,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: context.read<TileCacheService>(),
),
update:
@@ -284,6 +289,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
drawingProvider: drawings,
channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: tileCache,
),
),

View File

@@ -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();
}

View 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,
});
}

View File

@@ -25,6 +25,11 @@ import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart';
import '../services/image_preferences.dart';
import 'package:image_picker/image_picker.dart';
import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget {
@@ -50,6 +55,10 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient;
// Image sending state
bool _isSendingImage = false;
final ImagePicker _imagePicker = ImagePicker();
// Voice recording state
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false;
@@ -57,7 +66,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const int _maxVoicePackets = 10;
static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 1;
static const int _maxInteriorSilentChunks = 2;
bool get _voiceSupported => Platform.isIOS || Platform.isAndroid;
StreamSubscription<Int16List>? _voiceStreamSub;
String? _currentVoiceSessionId;
@@ -418,6 +427,137 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
// ── Image sending ───────────────────────────────────────────────────────────
Future<void> _pickAndSendImage({
ImageSource source = ImageSource.gallery,
}) async {
if (_isSendingImage) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
}
// Pick image.
final picked = await _imagePicker.pickImage(source: source);
if (picked == null) return;
final rawBytes = await picked.readAsBytes();
setState(() => _isSendingImage = true);
try {
// Compress to grayscale AVIF using user-selected size and compression.
final maxSize = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression();
final result = await ImageCodecService.compress(
rawBytes,
maxDimension: maxSize,
compression: compression,
);
if (result == null) {
ToastLogger.error(context, 'Image compression failed');
return;
}
final compressed = result.bytes;
// Generate session ID (4 random bytes → 8 hex chars).
final sessionId = List.generate(
8,
(_) => math.Random().nextInt(16).toRadixString(16),
).join();
// Fragment.
final fragments = fragmentImage(
sessionId: sessionId,
format: ImageFormat.avif,
bytes: compressed,
);
if (fragments.isEmpty) {
ToastLogger.error(context, 'Image fragmentation failed');
return;
}
// Build envelope.
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
ToastLogger.error(context, 'Device key unavailable');
return;
}
final senderKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final envelope = ImageEnvelope(
sessionId: sessionId,
format: ImageFormat.avif,
total: fragments.length,
width: result.width,
height: result.height,
sizeBytes: compressed.length,
senderKey6: senderKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
// Cache for deferred serving.
final imageProvider = context.read<ip.ImageProvider>();
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
// Add local placeholder message.
final messagesProvider = context.read<MessagesProvider>();
final msgId = 'img_${sessionId}_sent';
final isChannel =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
final placeholder = Message(
id: msgId,
messageType: isChannel ? MessageType.channel : MessageType.contact,
channelIdx: isChannel ? 0 : null,
senderPublicKeyPrefix: deviceKey.sublist(0, 6),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: envelope.encode(),
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
);
messagesProvider.addSentMessage(placeholder);
// Send IE1 envelope via normal message path.
final envelopeText = envelope.encode();
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: envelopeText,
messageId: msgId,
);
} else if (_selectedRecipient != null) {
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
text: envelopeText,
messageId: msgId,
contact: _selectedRecipient!,
);
if (!sent) {
messagesProvider.markMessageFailed(msgId);
ToastLogger.error(context, 'Failed to announce image');
}
}
debugPrint(
'📷 [Image] Sent IE1 for session $sessionId: '
'${fragments.length} fragments, ${compressed.length}B',
);
} catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
ToastLogger.error(context, 'Image send failed');
} finally {
if (mounted) setState(() => _isSendingImage = false);
}
}
// ── Voice recording ────────────────────────────────────────────────────────
Future<void> _startVoiceRecording() async {
@@ -476,6 +616,8 @@ class _MessagesTabState extends State<MessagesTab> {
final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled,
);
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen(
@@ -808,6 +950,28 @@ class _MessagesTabState extends State<MessagesTab> {
}
},
),
ListTile(
enabled: !_isSendingImage,
leading: const Icon(Icons.photo_library),
title: const Text('Send image from gallery'),
onTap: _isSendingImage
? null
: () {
Navigator.pop(sheetContext);
_pickAndSendImage(source: ImageSource.gallery);
},
),
ListTile(
enabled: !_isSendingImage,
leading: const Icon(Icons.camera_alt),
title: const Text('Take photo'),
onTap: _isSendingImage
? null
: () {
Navigator.pop(sheetContext);
_pickAndSendImage(source: ImageSource.camera);
},
),
],
),
);

View File

@@ -13,6 +13,7 @@ import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
import '../utils/sample_data_generator.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
@@ -47,6 +48,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality;
final LocationTrackingService _locationService = LocationTrackingService();
@override
@@ -58,6 +61,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_initializeLocationService();
_loadRxTxPreference();
_loadVoiceBitratePreference();
_loadImagePreferences();
}
@override
@@ -112,6 +116,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
return '$bitrate bps';
}
Future<void> _loadImagePreferences() async {
final size = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression();
if (!mounted) return;
setState(() {
_imageMaxSize = size;
_imageCompression = compression;
});
}
Future<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size);
if (!mounted) return;
setState(() => _imageMaxSize = size);
}
Future<void> _saveImageCompression(int compression) async {
await ImagePreferences.setCompression(compression);
if (!mounted) return;
setState(() => _imageCompression = compression);
}
Future<void> _initializeLocationService() async {
// Initialize location service with BLE service
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -581,6 +607,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => _buildVoiceStatsCard(
bitrate: _voiceBitrate,
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
@@ -604,6 +632,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.compress),
title: const Text('Voice compressor'),
subtitle: const Text('Balances quiet and loud speech levels'),
value: appProvider.isVoiceCompressorEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceCompressorEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
@@ -619,6 +669,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const Divider(),
// Image Settings Section
_buildSectionHeader('Image'),
ListTile(
leading: const Icon(Icons.photo_size_select_large),
title: const Text('Max image size'),
subtitle: Text('$_imageMaxSize×$_imageMaxSize px'),
trailing: const Icon(Icons.chevron_right),
onTap: _showImageMaxSizeDialog,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Image compression'),
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Slider(
value: _imageCompression.toDouble(),
min: 10,
max: 90,
divisions: 8,
label: '$_imageCompression',
onChanged: (v) => setState(() => _imageCompression = v.round()),
onChangeEnd: (v) => _saveImageCompression(v.round()),
),
),
const Divider(),
// Templates Section
_buildSectionHeader('Templates'),
ListTile(
@@ -841,6 +919,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _buildVoiceStatsCard({
required int bitrate,
required bool bandPassEnabled,
required bool compressorEnabled,
required bool limiterEnabled,
required bool silenceTrimEnabled,
}) {
final supported = VoiceBitratePreferences.supportedBitrates;
@@ -849,7 +929,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
final normalized = maxBitrate > minBitrate
? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0)
: 1.0;
final enabledCount = (bandPassEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0);
final enabledCount =
(bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -870,10 +954,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: normalized,
minHeight: 8,
),
child: LinearProgressIndicator(value: normalized, minHeight: 8),
),
const SizedBox(height: 10),
Row(
@@ -885,6 +966,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Compressor',
enabled: compressorEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Limiter',
enabled: limiterEnabled,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Silence trim',
@@ -895,7 +994,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Processing enabled: $enabledCount/2',
'Processing enabled: $enabledCount/4',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -1086,6 +1185,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showImageMaxSizeDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Max image size'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: ImagePreferences.supportedSizes
.map(
(size) => RadioListTile<int>(
value: size,
groupValue: _imageMaxSize,
title: Text('${size}×$size px'),
subtitle: size == ImagePreferences.defaultMaxSize
? const Text('Default')
: null,
onChanged: (value) {
if (value != null) _saveImageMaxSize(value);
Navigator.pop(context);
},
),
)
.toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
);
}
void _showVoiceBitrateDialog() {
showDialog(
context: context,
@@ -1107,7 +1242,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bitrate) => RadioListTile<int>(
value: bitrate,
title: Text('$bitrate bps'),
subtitle: bitrate == VoiceBitratePreferences.defaultBitrate
subtitle:
bitrate == VoiceBitratePreferences.defaultBitrate
? const Text('Default')
: null,
),

View File

@@ -0,0 +1,122 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter_avif/flutter_avif.dart';
/// Compresses and resizes an image for low-bandwidth mesh transmission.
///
/// Target: ≤256×256 pixels, grayscale AVIF at aggressive quality.
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
/// → 720 fragments at 152 bytes each.
class ImageCodecService {
/// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a
/// small grayscale AVIF suitable for mesh transmission.
///
/// [maxDimension] caps width and height (default 256); aspect ratio is
/// preserved and images smaller than the cap are not upscaled.
/// [compression] 0 = lossless, 100 = smallest/worst (libavif CQ scale).
///
/// Returns `(bytes, width, height)` or null if decoding or encoding fails.
static Future<({Uint8List bytes, int width, int height})?> compress(
Uint8List rawBytes, {
int maxDimension = 256,
int compression = 90,
}) async {
try {
// 1a. Probe original dimensions (no resize).
final probeCodec = await ui.instantiateImageCodec(rawBytes);
final probeFrame = await probeCodec.getNextFrame();
final srcW = probeFrame.image.width;
final srcH = probeFrame.image.height;
probeFrame.image.dispose();
// 1b. Compute contain dimensions: scale down only the limiting axis so
// the image fits within maxDimension×maxDimension without stretching.
int dstW = srcW;
int dstH = srcH;
if (srcW > maxDimension || srcH > maxDimension) {
if (srcW >= srcH) {
dstW = maxDimension;
dstH = (srcH * maxDimension / srcW).round().clamp(1, maxDimension);
} else {
dstH = maxDimension;
dstW = (srcW * maxDimension / srcH).round().clamp(1, maxDimension);
}
}
// 1c. Decode at the exact contain size (single axis constrained).
final codec = await ui.instantiateImageCodec(
rawBytes,
targetWidth: dstW,
targetHeight: dstH,
allowUpscaling: false,
);
final frame = await codec.getNextFrame();
final image = frame.image;
final w = image.width;
final h = image.height;
// 2. Export RGBA pixels.
final byteData = await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
image.dispose();
if (byteData == null) return null;
// 3. Convert to grayscale in-place (luminance, keep alpha = 255).
final rgba = byteData.buffer.asUint8List();
for (var i = 0; i < rgba.length; i += 4) {
final lum =
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
.round()
.clamp(0, 255);
rgba[i] = lum;
rgba[i + 1] = lum;
rgba[i + 2] = lum;
rgba[i + 3] = 255; // fully opaque
}
// 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it.
// encodeAvif() takes an encoded image (PNG/JPEG), not raw RGBA.
final buffer = await ui.ImmutableBuffer.fromUint8List(rgba);
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: w,
height: h,
pixelFormat: ui.PixelFormat.rgba8888,
);
final greyCodec = await descriptor.instantiateCodec();
final greyFrame = await greyCodec.getNextFrame();
final greyImage = greyFrame.image;
final pngData = await greyImage.toByteData(
format: ui.ImageByteFormat.png,
);
greyImage.dispose();
if (pngData == null) return null;
final pngBytes = pngData.buffer.asUint8List();
// 5. Encode PNG → AVIF.
// maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst).
// compression=90 maps to maxQuantizer≈57, minQuantizer≈37.
final maxQ = ((compression / 100) * 63).round().clamp(0, 63);
final minQ = (maxQ * 0.65).round().clamp(0, maxQ);
final avif = await encodeAvif(
pngBytes,
maxQuantizer: maxQ,
minQuantizer: minQ,
speed: 8, // fast encode (0 = slowest/best, 10 = fastest)
);
if (avif.isEmpty) return null;
debugPrint(
'📷 [ImageCodec] ${rawBytes.length}B → $w×$h grayscale AVIF '
'${avif.length}B (${(avif.length * 100 / rawBytes.length).round()}%)',
);
return (bytes: avif, width: w, height: h);
} catch (e, st) {
debugPrint('❌ [ImageCodec] compress error: $e\n$st');
return null;
}
}
}

View File

@@ -0,0 +1,35 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Stores user-selected image compression settings.
class ImagePreferences {
static const String _maxSizeKey = 'image_max_size';
// Keep the legacy key name so existing users retain their saved value.
static const String _qualityKey = 'image_quality';
static const int defaultMaxSize = 256;
static const int defaultQuality = 90;
static const List<int> supportedSizes = [64, 128, 256];
static Future<int> getMaxSize() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize;
return supportedSizes.contains(value) ? value : defaultMaxSize;
}
static Future<void> setMaxSize(int size) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_maxSizeKey, size);
}
static Future<int> getCompression() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_qualityKey) ?? defaultQuality;
return value.clamp(10, 90);
}
static Future<void> setCompression(int compression) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_qualityKey, compression.clamp(10, 90));
}
}

View File

@@ -25,10 +25,14 @@ class VoiceRecorderService {
///
/// [chunkDuration] controls how often samples are emitted (default 1 s).
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// [enableCompressor] normalizes speech dynamics before encoding.
/// [enableLimiter] protects against clipping peaks before encoding.
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1),
bool enableBandPassFilter = true,
bool enableCompressor = true,
bool enableLimiter = true,
}) {
if (_isRecording) {
throw StateError('VoiceRecorderService: already recording');
@@ -42,6 +46,8 @@ class VoiceRecorderService {
_startRecording(
chunkDuration,
enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor,
enableLimiter: enableLimiter,
);
return _controller!.stream;
}
@@ -49,6 +55,8 @@ class VoiceRecorderService {
Future<void> _startRecording(
Duration chunkDuration, {
required bool enableBandPassFilter,
required bool enableCompressor,
required bool enableLimiter,
}) async {
final config = const RecordConfig(
encoder: AudioEncoder.pcm16bits,
@@ -64,6 +72,11 @@ class VoiceRecorderService {
lowCutHz: 250.0,
highCutHz: 3400.0,
);
final dynamics = _VoiceDynamicsProcessor(
sampleRate: 8000,
enableCompressor: enableCompressor,
enableLimiter: enableLimiter,
);
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[];
@@ -74,18 +87,20 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
_controller?.add(
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
);
final filtered = enableBandPassFilter
? voiceFilter.process(pcm)
: pcm;
_controller?.add(dynamics.process(filtered));
}
},
onDone: () {
if (buffer.isNotEmpty) {
final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded));
_controller?.add(
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
);
final filtered = enableBandPassFilter
? voiceFilter.process(pcm)
: pcm;
_controller?.add(dynamics.process(filtered));
}
_controller?.close();
},
@@ -138,6 +153,107 @@ class VoiceRecorderService {
}
}
/// Light speech-focused dynamics processing.
///
/// Compressor improves low-level intelligibility; limiter prevents peaks that
/// can create harsh codec artifacts.
class _VoiceDynamicsProcessor {
final bool _enableCompressor;
final bool _enableLimiter;
final _SimpleCompressor _compressor;
final _PeakLimiter _limiter;
_VoiceDynamicsProcessor({
required int sampleRate,
required bool enableCompressor,
required bool enableLimiter,
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);
Int16List process(Int16List input) {
final output = Int16List(input.length);
for (var i = 0; i < input.length; i++) {
var sample = input[i].toDouble();
if (_enableCompressor) {
sample = _compressor.process(sample);
}
if (_enableLimiter) {
sample = _limiter.process(sample);
}
output[i] = sample.clamp(-32768.0, 32767.0).round();
}
return output;
}
}
/// Basic feed-forward compressor with attack/release smoothing.
class _SimpleCompressor {
final double _thresholdDb;
final double _ratio;
final double _makeupGain;
final double _attackCoeff;
final double _releaseCoeff;
static const double _eps = 1.0;
double _env = 0.0;
double _gain = 1.0;
_SimpleCompressor({
required double sampleRate,
required double thresholdDb,
required double ratio,
required double attackMs,
required double releaseMs,
required double makeupGainDb,
}) : _thresholdDb = thresholdDb,
_ratio = ratio,
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
double process(double x) {
final absX = x.abs();
final envCoeff = absX > _env ? _attackCoeff : _releaseCoeff;
_env = envCoeff * _env + (1.0 - envCoeff) * absX;
final envDb = 20.0 * math.log((_env + _eps) / 32768.0) / math.ln10;
var targetGain = 1.0;
if (envDb > _thresholdDb) {
final outDb = _thresholdDb + (envDb - _thresholdDb) / _ratio;
final gainDb = outDb - envDb;
targetGain = math.pow(10.0, gainDb / 20.0).toDouble();
}
targetGain *= _makeupGain;
final gainCoeff = targetGain < _gain ? _attackCoeff : _releaseCoeff;
_gain = gainCoeff * _gain + (1.0 - gainCoeff) * targetGain;
return x * _gain;
}
}
/// Hard peak limiter with fixed ceiling.
class _PeakLimiter {
final double _ceiling;
_PeakLimiter({required double ceilingDb})
: _ceiling = 32767.0 * math.pow(10.0, ceilingDb / 20.0).toDouble();
double process(double x) {
if (x > _ceiling) return _ceiling;
if (x < -_ceiling) return -_ceiling;
return x;
}
}
/// Band-pass filter tuned for human voice at 8 kHz input.
///
/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency

View File

@@ -0,0 +1,274 @@
import 'dart:typed_data';
/// Compressed image format used in the image packet protocol.
enum ImageFormat {
avif(0, 'AVIF'),
jpeg(1, 'JPEG');
const ImageFormat(this.id, this.label);
final int id;
final String label;
static ImageFormat fromId(int id) => ImageFormat.values.firstWhere(
(f) => f.id == id,
orElse: () => ImageFormat.avif,
);
}
/// A single binary fragment of a compressed image.
///
/// Binary format (direct contacts, via pushRawData / cmdSendRawData):
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...]
///
/// Max total packet size is 160 bytes → 152 bytes of image data per fragment.
class ImagePacket {
final String sessionId; // 8 hex chars (4 bytes)
final ImageFormat format;
final int index; // 0-based
final int total; // total fragment count (1..255)
final Uint8List data;
const ImagePacket({
required this.sessionId,
required this.format,
required this.index,
required this.total,
required this.data,
});
static const int _magic = 0x49; // 'I'
static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1)
static const int maxDataBytes = 152; // 160 - 8 header bytes
static bool isImageBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _magic;
static ImagePacket? tryParseBinary(Uint8List payload) {
if (payload.length < _headerLen) return null;
if (payload[0] != _magic) return null;
try {
final sessionId = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final fmtId = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
return ImagePacket(
sessionId: sessionId,
format: ImageFormat.fromId(fmtId),
index: index,
total: total,
data: payload.sublist(_headerLen),
);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
final sessionBytes = Uint8List(4);
for (var i = 0; i < 4; i++) {
sessionBytes[i] = int.parse(
sessionId.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
final out = Uint8List(_headerLen + data.length);
out[0] = _magic;
out.setRange(1, 5, sessionBytes);
out[5] = format.id;
out[6] = index;
out[7] = total;
out.setRange(_headerLen, out.length, data);
return out;
}
@override
String toString() =>
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)';
}
/// Envelope announcing image availability (control plane).
///
/// Text format:
/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
/// Example:
/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1
class ImageEnvelope {
static const String prefix = 'IE1:';
final String sessionId; // 8 hex chars
final ImageFormat format;
final int total; // total fragment count
final int width;
final int height;
final int sizeBytes; // total compressed image size
final String senderKey6; // 12 hex chars (6 bytes)
final int timestampSec;
final int version;
const ImageEnvelope({
required this.sessionId,
required this.format,
required this.total,
required this.width,
required this.height,
required this.sizeBytes,
required this.senderKey6,
required this.timestampSec,
this.version = 1,
});
static bool isEnvelope(String text) => text.startsWith(prefix);
static ImageEnvelope? tryParse(String text) {
if (!isEnvelope(text)) return null;
final body = text.substring(prefix.length);
final parts = body.split(':');
if (parts.length != 9) return null;
try {
final sid = parts[0];
final fmtId = int.tryParse(parts[1]);
final total = int.tryParse(parts[2]);
final w = int.tryParse(parts[3]);
final h = int.tryParse(parts[4]);
final bytes = int.tryParse(parts[5]);
final senderKey6 = parts[6];
final ts = int.tryParse(parts[7]);
final ver = int.tryParse(parts[8]);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
if (fmtId == null) return null;
if (total == null || total < 1 || total > 255) return null;
if (w == null || h == null || w < 1 || h < 1) return null;
if (bytes == null || bytes < 1) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return ImageEnvelope(
sessionId: sid.toLowerCase(),
format: ImageFormat.fromId(fmtId),
total: total,
width: w,
height: h,
sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: ver,
);
} catch (_) {
return null;
}
}
String encode() =>
'${prefix}${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version';
}
/// Direct request to fetch image fragments (control plane).
///
/// Text format:
/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
/// Example:
/// IR1:deadbeef:a:aabbccddeeff:1700000010:1
class ImageFetchRequest {
static const String prefix = 'IR1:';
final String sessionId;
final String want; // always 'all'
final String requesterKey6; // 12 hex chars
final int timestampSec;
final int version;
const ImageFetchRequest({
required this.sessionId,
this.want = 'all',
required this.requesterKey6,
required this.timestampSec,
this.version = 1,
});
static bool isRequest(String text) => text.startsWith(prefix);
static ImageFetchRequest? tryParse(String text) {
if (!isRequest(text)) return null;
final body = text.substring(prefix.length);
final parts = body.split(':');
if (parts.length != 5) return null;
try {
final sid = parts[0];
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]);
final normalizedWant = wantToken == 'a' ? 'all' : wantToken;
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
if (normalizedWant != 'all') return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return ImageFetchRequest(
sessionId: sid.toLowerCase(),
want: normalizedWant,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: ver,
);
} catch (_) {
return null;
}
}
String encode() {
final wantToken = want == 'all' ? 'a' : want;
return '${prefix}${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
}
}
/// Fragment the compressed image bytes into [ImagePacket] list.
///
/// [sessionId] must be 8 lowercase hex chars.
/// [format] is the image format used.
/// Returns at most 255 packets; excess bytes are silently dropped.
List<ImagePacket> fragmentImage({
required String sessionId,
required ImageFormat format,
required Uint8List bytes,
}) {
const chunkSize = ImagePacket.maxDataBytes;
final chunks = <Uint8List>[];
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = (offset + chunkSize).clamp(0, bytes.length);
chunks.add(bytes.sublist(offset, end));
if (chunks.length == 255) break; // protocol limit
}
final total = chunks.length;
return [
for (var i = 0; i < total; i++)
ImagePacket(
sessionId: sessionId,
format: format,
index: i,
total: total,
data: chunks[i],
),
];
}
/// Reassemble image bytes from received [packets].
///
/// Returns null if any fragment is missing.
Uint8List? reassembleImage(List<ImagePacket?> packets) {
if (packets.isEmpty) return null;
if (packets.any((p) => p == null)) return null;
final merged = <int>[];
for (final p in packets) {
merged.addAll(p!.data);
}
return Uint8List.fromList(merged);
}

View File

@@ -0,0 +1,280 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../utils/image_message_parser.dart';
/// A message bubble that shows a received or sent image.
///
/// On first render the image is not yet fetched (only the IE1 envelope is
/// known). The user taps the thumbnail placeholder → IR1 fetch request is
/// sent → binary fragments stream in → bubble rebuilds with the full image.
class ImageMessageBubble extends StatefulWidget {
final Message message;
final bool isSentByMe;
const ImageMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
State<ImageMessageBubble> createState() => _ImageMessageBubbleState();
}
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
bool _isRequesting = false;
String? _errorText;
@override
Widget build(BuildContext context) {
final envelope = ImageEnvelope.tryParse(widget.message.text);
if (envelope == null) return const SizedBox.shrink();
return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) {
final session = imageProvider.session(envelope.sessionId);
final isComplete = imageProvider.isComplete(envelope.sessionId);
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isRequesting = false);
});
}
final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope.total;
final imageBytes = isComplete ? session?.imageBytes : null;
return GestureDetector(
onTap: isComplete
? () => _showFullScreen(context, imageBytes!)
: null,
child: Container(
constraints: const BoxConstraints(maxWidth: 256),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Image area: 256×256 placeholder or actual image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildImageArea(
context,
imageBytes: imageBytes,
isComplete: isComplete,
isRequesting: _isRequesting,
received: received,
total: total,
envelope: envelope,
),
),
const SizedBox(height: 4),
// Status line
Text(
_statusText(
isComplete: isComplete,
isRequesting: _isRequesting,
received: received,
total: total,
envelope: envelope,
error: _errorText,
isSentByMe: widget.isSentByMe,
),
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
);
},
);
}
Widget _buildImageArea(
BuildContext context, {
required Uint8List? imageBytes,
required bool isComplete,
required bool isRequesting,
required int received,
required int total,
required ImageEnvelope envelope,
}) {
if (isComplete && imageBytes != null) {
return AspectRatio(
aspectRatio: 1.0,
child: AvifImage.memory(imageBytes, fit: BoxFit.cover),
);
}
// Placeholder with fetch/progress UI.
return AspectRatio(
aspectRatio: 1.0,
child: Container(
color: Colors.grey.shade800,
child: Stack(
alignment: Alignment.center,
children: [
if (isRequesting) ...[
// Download progress ring.
SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
value: total > 0 ? received / total : null,
strokeWidth: 3,
color: Theme.of(context).colorScheme.primary,
),
),
Text(
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
] else if (_errorText != null) ...[
const Icon(Icons.broken_image, color: Colors.red, size: 36),
] else ...[
// Tap-to-load icon.
IconButton(
onPressed: () => _requestAndFetch(envelope),
icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70,
tooltip: 'Load image',
),
],
],
),
),
);
}
Future<void> _requestAndFetch(ImageEnvelope envelope) async {
if (_isRequesting) return;
final sender = _resolveSender(envelope);
if (sender == null) {
setState(() => _errorText = 'Sender not reachable');
return;
}
final conn = context.read<ConnectionProvider>();
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
setState(() => _errorText = 'Device key unavailable');
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final request = ImageFetchRequest(
sessionId: envelope.sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
setState(() {
_isRequesting = true;
_errorText = null;
});
final sent = await conn.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encode(),
contact: sender,
);
if (!sent && mounted) {
setState(() {
_isRequesting = false;
_errorText = 'Image unavailable right now';
});
}
}
Contact? _resolveSender(ImageEnvelope envelope) {
final contactsProvider = context.read<ContactsProvider>();
final senderPrefix = widget.message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
final c = contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPrefix.sublist(0, 6)),
);
if (c != null) return c;
}
return contactsProvider.findContactByPrefixHex(envelope.senderKey6);
}
static String _statusText({
required bool isComplete,
required bool isRequesting,
required int received,
required int total,
required ImageEnvelope envelope,
required String? error,
required bool isSentByMe,
}) {
if (error != null) return error;
if (isRequesting) return '📥 Loading… $received/$total';
if (isComplete) {
final base =
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
return isSentByMe ? '$base · ${envelope.total} seg' : base;
}
return '🖼️ Tap to load · ${envelope.width}×${envelope.height}';
}
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
showGeneralDialog<void>(
context: context,
barrierColor: Colors.black,
barrierDismissible: true,
barrierLabel: 'Close image preview',
pageBuilder: (dialogContext, animation, secondaryAnimation) => Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: InteractiveViewer(
minScale: 1.0,
maxScale: 1000.0,
clipBehavior: Clip.none,
boundaryMargin: const EdgeInsets.all(100000),
child: SizedBox.expand(
child: AvifImage.memory(imageBytes, fit: BoxFit.cover),
),
),
),
Positioned(
top: 16,
right: 16,
child: SafeArea(
child: IconButton(
onPressed: () => Navigator.of(dialogContext).pop(),
icon: const Icon(Icons.close),
color: Colors.white,
tooltip: 'Close',
),
),
),
],
),
),
transitionBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 150),
);
}
}

View File

@@ -12,6 +12,7 @@ import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
import '../../providers/voice_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../contacts/direct_message_sheet.dart';
import '../drawing_minimap_preview.dart';
import '../../services/sar_template_service.dart';
@@ -19,9 +20,11 @@ import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
/// Reusable message bubble widget that displays messages with various types:
/// - Regular text messages (channel or direct)
@@ -297,6 +300,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final voiceProvider = context.read<VoiceProvider>();
final imageProvider = context.read<ip.ImageProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
widget.message.isSentMessage ||
@@ -342,6 +346,11 @@ class _MessageBubbleState extends State<MessageBubble> {
? voiceProvider.session(widget.message.voiceId!)
: null;
final imageEnvelope = ImageEnvelope.tryParse(widget.message.text);
final imageSession = imageEnvelope != null
? imageProvider.session(imageEnvelope.sessionId)
: null;
final senderPrefixHex = widget.message.senderPublicKeyPrefix
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
@@ -427,6 +436,37 @@ class _MessageBubbleState extends State<MessageBubble> {
}
}
if (imageEnvelope != null) {
rawLines.add('--- Image Technical ---');
rawLines.add('Envelope format: IE1');
rawLines.add('Session ID: ${imageEnvelope.sessionId}');
rawLines.add(
'Image format: ${imageEnvelope.format.label} (id=${imageEnvelope.format.id})',
);
rawLines.add(
'Dimensions: ${imageEnvelope.width}×${imageEnvelope.height}',
);
rawLines.add('Fragments total (envelope): ${imageEnvelope.total}');
rawLines.add('Compressed size (envelope): ${imageEnvelope.sizeBytes} B');
rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}');
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
rawLines.add('Envelope ver: ${imageEnvelope.version}');
if (imageSession != null) {
rawLines.add('Session present locally: yes');
rawLines.add(
'Fragments received/total: ${imageSession.receivedCount}/${imageSession.total}',
);
rawLines.add('Session complete: ${imageSession.isComplete}');
final kb = (imageSession.imageBytes?.length ?? 0) / 1024.0;
rawLines.add(
'Reassembled size: ${imageSession.imageBytes != null ? '${kb.toStringAsFixed(1)} kB' : '-'}',
);
} else {
rawLines.add('Session present locally: no');
}
}
final l10n = AppLocalizations.of(context)!;
void copyField(String value) {
@@ -1793,6 +1833,9 @@ class _MessageBubbleState extends State<MessageBubble> {
message.voiceId != null &&
!widget.isCompact)
VoiceMessageBubble(message: message, isSentByMe: isOwnMessage)
// Image message content (IE1 envelope)
else if (ImageEnvelope.isEnvelope(message.text) && !widget.isCompact)
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),