From 0e7ba5572de846a87b8ad22c79ce9d544f67a470 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 1 Mar 2026 19:17:09 +0100 Subject: [PATCH] Improve image zoom quality --- docs/image-mode-technical.md | 199 ++++++++++ ios/Podfile.lock | 19 + lib/main.dart | 6 + lib/providers/app_provider.dart | 138 ++++++- lib/providers/image_provider.dart | 360 ++++++++++++++++++ lib/screens/messages_tab.dart | 166 +++++++- lib/screens/settings_screen.dart | 150 +++++++- lib/services/image_codec_service.dart | 122 ++++++ lib/services/image_preferences.dart | 35 ++ lib/services/voice_recorder_service.dart | 128 ++++++- lib/utils/image_message_parser.dart | 274 +++++++++++++ .../messages/image_message_bubble.dart | 280 ++++++++++++++ lib/widgets/messages/message_bubble.dart | 43 +++ linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 224 +++++++++++ pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 20 files changed, 2157 insertions(+), 15 deletions(-) create mode 100644 docs/image-mode-technical.md create mode 100644 lib/providers/image_provider.dart create mode 100644 lib/services/image_codec_service.dart create mode 100644 lib/services/image_preferences.dart create mode 100644 lib/utils/image_message_parser.dart create mode 100644 lib/widgets/messages/image_message_bubble.dart diff --git a/docs/image-mode-technical.md b/docs/image-mode-technical.md new file mode 100644 index 0000000..6be4407 --- /dev/null +++ b/docs/image-mode-technical.md @@ -0,0 +1,199 @@ +# Image Mode Technical Design + +## 1. Overview + +Image mode mirrors the voice on-demand architecture exactly: + +- **Control plane (text messages):** + - `IE1:` image envelope announces image availability in chat. + - `IR1:` direct fetch request asks sender to stream image fragments. +- **Data plane (raw binary packets):** + - `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`. + +Images are never broadcast in full to channels. Chat carries only metadata; +pixels are fetched on demand when the user taps the image bubble. + +## 2. Key Modules + +- `lib/utils/image_message_parser.dart` + - `ImagePacket` (binary fragment format) + - `ImageEnvelope` (`IE1`) + - `ImageFetchRequest` (`IR1`) + - `fragmentImage()` — split compressed bytes into packets + - `reassembleImage()` — join received fragments into bytes +- `lib/screens/messages_tab.dart` + - Pick image, compress to 128×128 grayscale AVIF, cache, send envelope +- `lib/providers/image_provider.dart` + - Reassembly sessions, outgoing cache, deferred serving +- `lib/providers/app_provider.dart` + - Incoming routing for `IE1`, `IR1`, binary `0x49` packets +- `lib/widgets/messages/image_message_bubble.dart` + - Placeholder with tap-to-load; progress ring during fetch; full image view +- `lib/services/image_codec_service.dart` + - `ImageCodecService.compress()` — `dart:ui` resize + grayscale + AVIF encode + +## 3. Wire Formats + +### 3.1 Image Envelope (`IE1`) + +Prefix: `IE1:` + colon-delimited payload + +Fields: + +| Field | Type | Description | +|-------------|--------|------------------------------------------| +| `sid` | string | 8 hex chars (4 bytes), session ID | +| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) | +| `total` | int | Fragment count (1..255) | +| `w` | int | Image width (pixels) | +| `h` | int | Image height (pixels) | +| `bytes` | int | Total compressed size in bytes | +| `senderKey6`| string | 12 hex chars (6 bytes sender prefix) | +| `ts` | int | Unix timestamp (seconds) | +| `ver` | int | Protocol version (currently `1`) | + +Compact format: + +```text +IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver} +``` + +Example: + +```text +IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1 +``` + +### 3.2 Image Fetch Request (`IR1`) + +Same structure as `VR1`: + +```text +IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} +``` + +| Field | Value | +|----------------|--------| +| `want` | `a` (= "all fragments") | +| `requesterKey6`| 12 hex chars | +| `ver` | `1` | + +### 3.3 Raw Image Packet (data plane) + +Binary payload structure: + +- Byte 0: magic `0x49` (`'I'`) +- Bytes 1..4: session ID (4 bytes) +- Byte 5: format ID +- Byte 6: fragment index (0-based) +- Byte 7: total fragments +- Bytes 8..N: image data (max 152 bytes per fragment) + +Header is 8 bytes — identical layout to `VoicePacket`. + +## 4. Compression Pipeline + +1. Source image (any format) is decoded via `dart:ui.instantiateImageCodec` + with `targetWidth: 128, targetHeight: 128`. +2. RGBA pixels exported via `image.toByteData(format: rawRgba)`. +3. Converted to grayscale (luminance `0.299R + 0.587G + 0.114B`) in-place. +4. Encoded to AVIF via `flutter_avif.encodeAvif()` with: + - `quality: 60` (CQ scale — lower = better quality) + - `speed: 8` (fast encode) +5. Fragmented at 152 bytes per packet. + +### Expected sizes (128×128 grayscale AVIF) + +| Quality | Approx size | Fragments | +|---------|-------------|-----------| +| 40 | 400–800 B | 3–6 | +| 60 | 600–1400 B | 4–10 | +| 80 | 1000–2500 B | 7–17 | + +Quality 60 targets 4–9 fragments — comparable to a 10-second voice session. + +## 5. Outgoing Flow (Send) + +1. User picks image from gallery or camera (`image_picker`). +2. `ImageCodecService.compress()` produces small grayscale AVIF bytes. +3. `fragmentImage()` splits bytes into `ImagePacket` list (≤255 fragments). +4. Fragments cached in `ImageProvider` (TTL 15 min). +5. `ImageEnvelope` is sent via normal message path: + - Channel: `sendChannelMessage` + - Direct: `sendTextMessage` +6. Local placeholder message added with `IE1:` text and `deliveryStatus.sending`. + +## 6. Incoming Flow (Receive) + +### 6.1 `IE1` envelope received + +`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to chat. The bubble shows a grey placeholder with a download icon. + +### 6.2 `IR1` request received + +`AppProvider` treats it as control-plane only (not added to chat): + +- Validates requester key prefix matches sender metadata. +- Resolves requester contact. +- Calls `imageProvider.serveSessionTo()` which streams all fragments. + +### 6.3 Raw packet received (`pushRawData`, magic `0x49`) + +`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls +`imageProvider.addFragment()`. When the session becomes complete, the bubble +automatically rebuilds with the full image. + +## 7. Outgoing Cache Details + +`ImageProvider` outgoing cache: + +- key: `sessionId` +- value: encoded fragment list + cached envelope + timestamp +- TTL: 15 minutes +- eviction: lazy on access + +## 8. Display + +`ImageMessageBubble`: + +- **Complete session**: 128×128 `AvifImage.memory()` widget; tap → full-screen `InteractiveViewer`. +- **Incomplete/missing**: grey placeholder with download icon; tap → sends IR1. +- **Loading**: circular progress with `received/total` count. +- **Error**: "Image unavailable right now" text. + +## 9. Persistence + +`ImageProvider` stores sessions in `SharedPreferences` under key +`stored_image_sessions_v1`: + +- Incoming: fragment list serialized as base64 binary packets. +- Outgoing: fragment list + envelope text + `cachedAt` timestamp. +- Expired outgoing sessions (> 15 min) are not restored. + +## 10. Operational Constraints + +- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`). +- On-demand fetch works only if sender app is online and has cached session. +- Raw return path requires a valid direct route to requester. +- Image compression uses `flutter_avif` — the `encodeAvif()` top-level + function must be available (verify against installed package version). + +## 11. High-Level Sequence + +```mermaid +sequenceDiagram + participant A as Sender App + participant M as Mesh Chat + participant B as Receiver App + + A->>A: Pick + compress image (128×128 grayscale AVIF) + A->>A: Fragment into ≤152B packets + A->>A: Cache fragments (TTL 15m) + A->>M: Send IE1 envelope + M->>B: Deliver IE1 + B->>B: Render placeholder bubble + B->>A: Send IR1 request on Tap + A->>B: Stream binary ImagePackets + B->>B: Reassemble fragments + B->>B: Display AVIF image +``` diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 36e4431..13d7b30 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -41,6 +41,8 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) + - flutter_avif_ios (0.0.1): + - Flutter - flutter_background_service_ios (0.0.3): - Flutter - flutter_blue_plus_darwin (0.0.2): @@ -53,6 +55,8 @@ PODS: - geolocator_apple (1.2.0): - Flutter - FlutterMacOS + - image_picker_ios (0.0.1): + - Flutter - nsd_ios (0.0.1): - Flutter - ObjectBox (4.4.1) @@ -73,6 +77,9 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter @@ -85,11 +92,13 @@ DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_compass (from `.symlinks/plugins/flutter_compass/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -97,6 +106,7 @@ DEPENDENCIES: - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - vibration (from `.symlinks/plugins/vibration/ios`) @@ -119,6 +129,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_avif_ios: + :path: ".symlinks/plugins/flutter_avif_ios/ios" flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" flutter_blue_plus_darwin: @@ -129,6 +141,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" nsd_ios: :path: ".symlinks/plugins/nsd_ios/ios" objectbox_flutter_libs: @@ -143,6 +157,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" vibration: @@ -156,11 +172,13 @@ SPEC CHECKSUMS: DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 @@ -170,6 +188,7 @@ SPEC CHECKSUMS: SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb diff --git a/lib/main.dart b/lib/main.dart index eefea45..bab8edb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { ), ), + // 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 { drawingProvider: context.read(), channelsProvider: context.read(), voiceProvider: context.read(), + imageProvider: context.read(), tileCacheService: context.read(), ), update: @@ -284,6 +289,7 @@ class _MeshCoreSarAppState extends State { drawingProvider: drawings, channelsProvider: channels, voiceProvider: context.read(), + imageProvider: context.read(), tileCacheService: tileCache, ), ), diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index ef22b22..9fe9d1f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -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 _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 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 _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 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 _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(); } diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart new file mode 100644 index 0000000..2aaaca5 --- /dev/null +++ b/lib/providers/image_provider.dart @@ -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 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 _sessions = {}; + + /// Outgoing sessions cached for deferred serving. + final Map _outgoing = {}; + + /// Hook for sending a raw binary payload to a contact. + Future 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 fragments, + ImageEnvelope envelope, + ) { + if (fragments.isEmpty) return; + _evictExpiredOutgoing(); + _outgoing[sessionId] = _OutgoingSession( + sessionId: sessionId, + fragments: List.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 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 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 _persist() async { + try { + _evictExpiredOutgoing(); + final prefs = await SharedPreferences.getInstance(); + final payload = { + '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 _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; + + for (final item in (parsed['incoming'] as List? ?? [])) { + final map = item as Map; + 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? ?? []; + 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? ?? [])) { + final map = item as Map; + 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? ?? []; + final fragments = []; + 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 fragments; + final ImageEnvelope envelope; + final DateTime cachedAt; + + const _OutgoingSession({ + required this.sessionId, + required this.fragments, + required this.envelope, + required this.cachedAt, + }); +} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 1308fd3..82d21be 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -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 { 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 { 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? _voiceStreamSub; String? _currentVoiceSessionId; @@ -418,6 +427,137 @@ class _MessagesTabState extends State { } } + // ── Image sending ─────────────────────────────────────────────────────────── + + Future _pickAndSendImage({ + ImageSource source = ImageSource.gallery, + }) async { + if (_isSendingImage) return; + final connectionProvider = context.read(); + 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(); + imageProvider.cacheOutgoingSession(sessionId, fragments, envelope); + + // Add local placeholder message. + final messagesProvider = context.read(); + 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 _startVoiceRecording() async { @@ -476,6 +616,8 @@ class _MessagesTabState extends State { 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 { } }, ), + 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); + }, + ), ], ), ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9d805db..c060d2d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { 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 { _initializeLocationService(); _loadRxTxPreference(); _loadVoiceBitratePreference(); + _loadImagePreferences(); } @override @@ -112,6 +116,28 @@ class _SettingsScreenState extends State { return '$bitrate bps'; } + Future _loadImagePreferences() async { + final size = await ImagePreferences.getMaxSize(); + final compression = await ImagePreferences.getCompression(); + if (!mounted) return; + setState(() { + _imageMaxSize = size; + _imageCompression = compression; + }); + } + + Future _saveImageMaxSize(int size) async { + await ImagePreferences.setMaxSize(size); + if (!mounted) return; + setState(() => _imageMaxSize = size); + } + + Future _saveImageCompression(int compression) async { + await ImagePreferences.setCompression(compression); + if (!mounted) return; + setState(() => _imageCompression = compression); + } + Future _initializeLocationService() async { // Initialize location service with BLE service WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -581,6 +607,8 @@ class _SettingsScreenState extends State { 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 { }, ), ), + Consumer( + 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( + 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( builder: (context, appProvider, child) => SwitchListTile( secondary: const Icon(Icons.content_cut), @@ -619,6 +669,34 @@ class _SettingsScreenState extends State { ), 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 { 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 { 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 { 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 { ), ), 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 { ), 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 { ); } + 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( + 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 { (bitrate) => RadioListTile( value: bitrate, title: Text('$bitrate bps'), - subtitle: bitrate == VoiceBitratePreferences.defaultBitrate + subtitle: + bitrate == VoiceBitratePreferences.defaultBitrate ? const Text('Default') : null, ), diff --git a/lib/services/image_codec_service.dart b/lib/services/image_codec_service.dart new file mode 100644 index 0000000..cac12d6 --- /dev/null +++ b/lib/services/image_codec_service.dart @@ -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. +/// → 7–20 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; + } + } +} diff --git a/lib/services/image_preferences.dart b/lib/services/image_preferences.dart new file mode 100644 index 0000000..89c17d6 --- /dev/null +++ b/lib/services/image_preferences.dart @@ -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 supportedSizes = [64, 128, 256]; + + static Future getMaxSize() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize; + return supportedSizes.contains(value) ? value : defaultMaxSize; + } + + static Future setMaxSize(int size) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_maxSizeKey, size); + } + + static Future getCompression() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getInt(_qualityKey) ?? defaultQuality; + return value.clamp(10, 90); + } + + static Future setCompression(int compression) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_qualityKey, compression.clamp(10, 90)); + } +} diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart index eb94ef4..f2ad576 100644 --- a/lib/services/voice_recorder_service.dart +++ b/lib/services/voice_recorder_service.dart @@ -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 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 _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 = []; @@ -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 diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart new file mode 100644 index 0000000..89e4673 --- /dev/null +++ b/lib/utils/image_message_parser.dart @@ -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 fragmentImage({ + required String sessionId, + required ImageFormat format, + required Uint8List bytes, +}) { + const chunkSize = ImagePacket.maxDataBytes; + final chunks = []; + 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 packets) { + if (packets.isEmpty) return null; + if (packets.any((p) => p == null)) return null; + final merged = []; + for (final p in packets) { + merged.addAll(p!.data); + } + return Uint8List.fromList(merged); +} diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart new file mode 100644 index 0000000..838caa0 --- /dev/null +++ b/lib/widgets/messages/image_message_bubble.dart @@ -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 createState() => _ImageMessageBubbleState(); +} + +class _ImageMessageBubbleState extends State { + 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( + 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 _requestAndFetch(ImageEnvelope envelope) async { + if (_isRequesting) return; + final sender = _resolveSender(envelope); + if (sender == null) { + setState(() => _errorText = 'Sender not reachable'); + return; + } + + final conn = context.read(); + 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(); + 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( + 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), + ); + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 77d382b..4d23f0d 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -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 { final connectionProvider = context.read(); final contactsProvider = context.read(); final voiceProvider = context.read(); + final imageProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; final isOwnMessage = widget.message.isSentMessage || @@ -342,6 +346,11 @@ class _MessageBubbleState extends State { ? 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 { } } + 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 { 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), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 485f549..71a2375 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,8 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include #include @@ -15,6 +17,12 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); + flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin"); objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a4f4d51..4de5e13 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + file_selector_linux + flutter_avif_linux objectbox_flutter_libs record_linux url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d6e480b..a2ed06c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,8 @@ import Foundation import audioplayers_darwin import device_info_plus import file_picker +import file_selector_macos +import flutter_avif_macos import flutter_blue_plus_darwin import flutter_local_notifications import geolocator_apple @@ -17,12 +19,15 @@ import package_info_plus import record_macos import share_plus import shared_preferences_foundation +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) @@ -32,5 +37,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 5ef1284..7c77009 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -234,6 +234,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + exif: + dependency: transitive + description: + name: exif + sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172 + url: "https://pub.dev" + source: hosted + version: "3.3.0" fake_async: dependency: transitive description: @@ -266,6 +274,38 @@ packages: url: "https://pub.dev" source: hosted version: "10.3.10" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -287,6 +327,70 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_avif: + dependency: "direct main" + description: + name: flutter_avif + sha256: "6035f073189c1ae134affa73338c2eca79bf412e9abdb8f8628fdefcccff4444" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_android: + dependency: transitive + description: + name: flutter_avif_android + sha256: "1da135ea0d74225fae3154f954f9ba4cf7aa5e56b30d9851fcfc0ddecab9d8c5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_ios: + dependency: transitive + description: + name: flutter_avif_ios + sha256: "3daaa599d8fe0193d3ade6cafa1fd4f166d4063b8d499c7c311ce1d170b2759f" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_linux: + dependency: transitive + description: + name: flutter_avif_linux + sha256: dd6179edca12c720761b3acc678d7f15208eb8647adbac5a6a90f52d4ba56e2d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_macos: + dependency: transitive + description: + name: flutter_avif_macos + sha256: "8e39f365dfc5713d4527f07a92399184857ea30eced85f82171bfd0a45e2c73b" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_platform_interface: + dependency: transitive + description: + name: flutter_avif_platform_interface + sha256: f5c110bdb7de2d4ab4902bfe2ec331fe24bc19b6d54f00ac4fa165effc0edcda + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_web: + dependency: transitive + description: + name: flutter_avif_web + sha256: db2ff395b08cbfe9116e48ac65c428ba21a9159972ea019f8d7be580ff6cf6e8 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_windows: + dependency: transitive + description: + name: flutter_avif_windows + sha256: e0c7722df305a6621f4ecdd83098025c705bcf0e8ce9519e03c6f515abcf351d + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_background_service: dependency: "direct main" description: @@ -375,6 +479,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.18" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_compass: dependency: "direct main" description: @@ -598,6 +710,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -1196,6 +1372,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqlite3: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7a6c882..b41d68f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -97,6 +97,10 @@ dependencies: path_provider: ^2.1.5 file_picker: ^10.3.3 + # Image messaging (pick + AVIF encode/decode for mesh transmission) + image_picker: ^1.1.2 + flutter_avif: ^3.1.0 + # Persistent storage shared_preferences: ^2.3.3 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7b1bb5b..23d7703 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,8 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include #include @@ -19,6 +21,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterAvifWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); FlutterBluePlusPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); GeolocatorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 672c58c..5b38d53 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows + file_selector_windows + flutter_avif_windows flutter_blue_plus_winrt geolocator_windows nsd_windows