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