diff --git a/docs/image-mode-technical.md b/docs/image-mode-technical.md index 7f72a69..8168746 100644 --- a/docs/image-mode-technical.md +++ b/docs/image-mode-technical.md @@ -205,7 +205,34 @@ Status line below thumbnail: - Receiver (pending): `🖼️ Tap to load · {w}×{h}` - Loading: `📥 Loading… {received}/{total}` -## 9. Persistence +## 9. Transmit Time Estimate (UI) + +Image bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`). + +The estimate is airtime-based (LoRa packet model), not just compressed image size: + +- Source inputs: + - `total` fragments and `bytes` from `IE1` envelope + - `pathLen` from message metadata + - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` +- Per-fragment payload model: + - `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes` +- LoRa airtime: + - standard symbol-time formula (preamble + payload symbols) +- Mesh pacing/hops: + - multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0` + - multiplied by hop count `(pathLen + 1)` +- Total estimate: + - sum over all fragments + +BW handling: + +- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`) +- If `radioBw > 1000`, it is treated as Hz directly + +Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`. + +## 10. Persistence `ImageProvider` stores sessions in `SharedPreferences` under key `stored_image_sessions_v1`: @@ -214,14 +241,14 @@ Status line below thumbnail: - Outgoing: fragment list + envelope text + `cachedAt` timestamp. - Expired outgoing sessions (> 15 min) are not restored on startup. -## 10. Operational Constraints +## 11. 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. - Available on iOS and Android (`image_picker` + `flutter_avif`). -## 11. High-Level Sequence +## 12. High-Level Sequence ```mermaid sequenceDiagram diff --git a/docs/voice-mode-technical.md b/docs/voice-mode-technical.md index e81bddc..88f9b28 100644 --- a/docs/voice-mode-technical.md +++ b/docs/voice-mode-technical.md @@ -175,19 +175,47 @@ Parser validation enforces: `VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk. -## 10. Operational Constraints +## 10. Transmit Time Estimate (UI) + +Voice bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`). + +The estimate is airtime-based (LoRa packet model), not file-duration-only: + +- Source inputs: + - `packetCount` and `durationMs` from `VE1` envelope, or + - actual received `VoicePacket.codec2Data.length` bytes when local session packets exist + - `pathLen` from message metadata + - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` +- Per-packet payload model: + - `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes` +- LoRa airtime: + - standard symbol-time formula (preamble + payload symbols) +- Mesh pacing/hops: + - multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0` + - multiplied by hop count `(pathLen + 1)` +- Total estimate: + - sum over all packets + +BW handling: + +- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`) +- If `radioBw > 1000`, it is treated as Hz directly + +Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`. + +## 11. Operational Constraints - No firmware changes required. - On-demand fetch works only if sender app is online and has cached session. - Raw return path needs a currently valid direct route to requester. - Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`). -## 11. Backward Compatibility +## 12. Backward Compatibility - Legacy `V:` text packet parsing is still supported. - Message voice detection accepts both new `VE1` and legacy `V:` formats. -## 12. High-Level Sequence +## 13. High-Level Sequence ```mermaid sequenceDiagram diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 70ff2d6..a6b54ec 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -450,11 +450,17 @@ class _MessagesTabState extends State { final maxSize = await ImagePreferences.getMaxSize(); final compression = await ImagePreferences.getCompression(); final grayscale = await ImagePreferences.getGrayscale(); + final ultraMode = await ImagePreferences.getUltraMode(); + final effectiveMaxSize = ImagePreferences.effectiveMaxSize( + maxSize, + ultraMode: ultraMode, + ); final result = await ImageCodecService.compress( rawBytes, - maxDimension: maxSize, + maxDimension: effectiveMaxSize, compression: compression, grayscale: grayscale, + ultraMode: ultraMode, ); if (result == null) { if (!mounted) return; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f0261dc..4716ff7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,5 +1,8 @@ import 'dart:io' show Platform; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter_avif/flutter_avif.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; @@ -9,12 +12,17 @@ import 'package:url_launcher/url_launcher.dart'; import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; import '../services/update_checker_service.dart'; +import '../services/voice_codec_service.dart'; import '../services/voice_bitrate_preferences.dart'; import '../services/image_preferences.dart'; +import '../services/image_codec_service.dart'; import '../utils/sample_data_generator.dart'; +import '../utils/image_message_parser.dart'; +import '../utils/voice_message_parser.dart'; import '../theme/app_theme.dart'; import '../l10n/app_localizations.dart'; import '../widgets/connection_mode_selector.dart'; @@ -51,6 +59,13 @@ class _SettingsScreenState extends State { int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageCompression = ImagePreferences.defaultQuality; bool _imageGrayscale = ImagePreferences.defaultGrayscale; + bool _imageUltraMode = ImagePreferences.defaultUltraMode; + Uint8List? _previewSourceBytes; + String? _previewSourceName; + Uint8List? _previewCompressedBytes; + bool _isPreviewLoading = false; + bool _showCurrentImagePreview = true; + final ImagePicker _imagePicker = ImagePicker(); final LocationTrackingService _locationService = LocationTrackingService(); @override @@ -121,24 +136,88 @@ class _SettingsScreenState extends State { final size = await ImagePreferences.getMaxSize(); final compression = await ImagePreferences.getCompression(); final grayscale = await ImagePreferences.getGrayscale(); + final ultraMode = await ImagePreferences.getUltraMode(); if (!mounted) return; setState(() { - _imageMaxSize = size; + _imageMaxSize = ImagePreferences.effectiveMaxSize( + size, + ultraMode: ultraMode, + ); _imageCompression = compression; _imageGrayscale = grayscale; + _imageUltraMode = ultraMode; }); + await _refreshImageModePreview(); } Future _saveImageMaxSize(int size) async { await ImagePreferences.setMaxSize(size); if (!mounted) return; setState(() => _imageMaxSize = size); + await _refreshImageModePreview(); } Future _saveImageCompression(int compression) async { await ImagePreferences.setCompression(compression); if (!mounted) return; setState(() => _imageCompression = compression); + await _refreshImageModePreview(); + } + + Future _refreshImageModePreview() async { + final sourceBytes = _previewSourceBytes; + if (sourceBytes == null) { + if (!mounted) return; + setState(() => _previewCompressedBytes = null); + return; + } + + setState(() => _isPreviewLoading = true); + try { + final result = await ImageCodecService.compress( + sourceBytes, + maxDimension: ImagePreferences.effectiveMaxSize( + _imageMaxSize, + ultraMode: _imageUltraMode, + ), + compression: _imageCompression, + grayscale: _imageGrayscale, + ultraMode: _imageUltraMode, + ); + if (!mounted) return; + setState(() { + _previewCompressedBytes = result?.bytes; + _isPreviewLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _previewCompressedBytes = null; + _isPreviewLoading = false; + }); + } + } + + Future _selectPreviewImageFromGallery() async { + try { + final picked = await _imagePicker.pickImage(source: ImageSource.gallery); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (!mounted) return; + setState(() { + _previewSourceBytes = bytes; + _previewSourceName = picked.name; + }); + await _refreshImageModePreview(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to load preview image: $e'), + backgroundColor: Colors.red, + ), + ); + } } Future _initializeLocationService() async { @@ -606,14 +685,16 @@ class _SettingsScreenState extends State { // Voice Settings Section _buildSectionHeader('Voice'), - Consumer( - builder: (context, appProvider, child) => _buildVoiceStatsCard( - bitrate: _voiceBitrate, - bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, - compressorEnabled: appProvider.isVoiceCompressorEnabled, - limiterEnabled: appProvider.isVoiceLimiterEnabled, - silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, - ), + Consumer2( + builder: (context, appProvider, connectionProvider, child) => + _buildVoiceStatsCard( + bitrate: _voiceBitrate, + connectionProvider: connectionProvider, + bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, + compressorEnabled: appProvider.isVoiceCompressorEnabled, + limiterEnabled: appProvider.isVoiceLimiterEnabled, + silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, + ), ), ListTile( leading: const Icon(Icons.graphic_eq), @@ -708,8 +789,28 @@ class _SettingsScreenState extends State { onChanged: (value) async { await ImagePreferences.setGrayscale(value); setState(() => _imageGrayscale = value); + await _refreshImageModePreview(); }, ), + SwitchListTile( + secondary: const Icon(Icons.compress), + title: const Text('Ultra mode'), + subtitle: const Text( + 'Extra-aggressive compression with stronger AVIF settings', + ), + value: _imageUltraMode, + onChanged: (value) async { + await ImagePreferences.setUltraMode(value); + setState(() { + _imageUltraMode = value; + }); + await _refreshImageModePreview(); + }, + ), + Consumer( + builder: (context, connectionProvider, child) => + _buildImageModePreviewCard(connectionProvider), + ), const Divider(), // Templates Section @@ -931,8 +1032,187 @@ class _SettingsScreenState extends State { ); } + Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) { + final sourceBytes = _previewSourceBytes; + final fileName = _previewSourceName ?? 'No image selected'; + final radioBw = connectionProvider.deviceInfo.radioBw; + final radioSf = connectionProvider.deviceInfo.radioSf; + final radioCr = connectionProvider.deviceInfo.radioCr; + final bwHz = _resolveBandwidthHz(radioBw); + final previewSizeBytes = _previewCompressedBytes?.length ?? 0; + final directChunk = safeImageDataBytesForPath(0); + final twoHopChunk = safeImageDataBytesForPath(2); + final directFragments = previewSizeBytes > 0 + ? (previewSizeBytes + directChunk - 1) ~/ directChunk + : 0; + final twoHopFragments = previewSizeBytes > 0 + ? (previewSizeBytes + twoHopChunk - 1) ~/ twoHopChunk + : 0; + final imageDirect = estimateImageTransmitDuration( + fragmentCount: directFragments, + sizeBytes: previewSizeBytes, + pathLen: 0, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final imageTwoHop = estimateImageTransmitDuration( + fragmentCount: twoHopFragments, + sizeBytes: previewSizeBytes, + pathLen: 2, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.photo_library_outlined), + SizedBox(width: 8), + Text( + 'Image mode preview', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + 'Source image', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + FilledButton.icon( + onPressed: _isPreviewLoading + ? null + : _selectPreviewImageFromGallery, + icon: const Icon(Icons.photo_library_outlined), + label: const Text('Select from gallery'), + ), + ], + ), + const SizedBox(height: 6), + Text( + fileName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.8), + ), + ), + if (previewSizeBytes > 0) ...[ + const SizedBox(height: 4), + Text( + 'Preview tx (this image): Direct ${_formatEstimateDuration(imageDirect)} • 2-hop ${_formatEstimateDuration(imageTwoHop)}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + _showCurrentImagePreview + ? 'Showing: current image mode' + : 'Showing: source image', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Switch.adaptive( + value: _showCurrentImagePreview, + onChanged: (value) { + setState(() => _showCurrentImagePreview = value); + }, + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 1, + child: _showCurrentImagePreview + ? (_isPreviewLoading + ? const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ) + : (_previewCompressedBytes != null + ? AvifImage.memory( + _previewCompressedBytes!, + fit: BoxFit.cover, + ) + : Container(color: Colors.black12))) + : (sourceBytes != null + ? Image.memory(sourceBytes, fit: BoxFit.cover) + : Container(color: Colors.black12)), + ), + ), + ], + ), + ), + ); + } + + String _formatEstimateDuration(Duration value) { + if (value.inSeconds < 60) return '~${value.inSeconds}s'; + return '~${value.inMinutes}m ${value.inSeconds % 60}s'; + } + + static String _formatBandwidthLabel(int bwHz) { + if (bwHz >= 1000000) return '${(bwHz / 1000000).toStringAsFixed(2)} MHz'; + if (bwHz >= 1000) return '${(bwHz / 1000).toStringAsFixed(1)} kHz'; + return '$bwHz Hz'; + } + + int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return 250000; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return 250000; + } + } + Widget _buildVoiceStatsCard({ required int bitrate, + required ConnectionProvider connectionProvider, required bool bandPassEnabled, required bool compressorEnabled, required bool limiterEnabled, @@ -949,6 +1229,33 @@ class _SettingsScreenState extends State { (compressorEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0); + final radioBw = connectionProvider.deviceInfo.radioBw; + final radioSf = connectionProvider.deviceInfo.radioSf; + final radioCr = connectionProvider.deviceInfo.radioCr; + final bwHz = _resolveBandwidthHz(radioBw); + final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); + const voicePreviewMs = 10000; // 10-second reference clip + final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; + final voicePacketCount = + (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; + final voiceDirect = estimateVoiceTransmitDuration( + mode: voiceMode, + packetCount: voicePacketCount, + durationMs: voicePreviewMs, + pathLen: 0, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final voiceTwoHop = estimateVoiceTransmitDuration( + mode: voiceMode, + packetCount: voicePacketCount, + durationMs: voicePreviewMs, + pathLen: 2, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -966,6 +1273,19 @@ class _SettingsScreenState extends State { 'Bitrate: $bitrate bps', style: Theme.of(context).textTheme.bodySmall, ), + const SizedBox(height: 4), + Text( + 'Preview tx (10s ${voiceMode.label}): Direct ${_formatEstimateDuration(voiceDirect)} • 2-hop ${_formatEstimateDuration(voiceTwoHop)}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), diff --git a/lib/services/image_codec_service.dart b/lib/services/image_codec_service.dart index 03f3d4f..ed09ca7 100644 --- a/lib/services/image_codec_service.dart +++ b/lib/services/image_codec_service.dart @@ -9,6 +9,9 @@ import 'package:flutter_avif/flutter_avif.dart'; /// A typical 256×256 grayscale AVIF at quality 90 is highly compressed. /// → 7–20 fragments at 152 bytes each. class ImageCodecService { + static const int _normalAvifSpeed = 6; + static const int _ultraAvifSpeed = 4; + /// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a /// small grayscale AVIF suitable for mesh transmission. /// @@ -22,8 +25,15 @@ class ImageCodecService { int maxDimension = 256, int compression = 90, bool grayscale = true, + bool ultraMode = false, }) async { try { + final effectiveMaxDimension = maxDimension.clamp(32, 1024); + final effectiveCompression = ultraMode + ? (compression + 12).clamp(10, 100) + : compression.clamp(10, 100); + final forceGrayscale = ultraMode ? true : grayscale; + // 1a. Probe original dimensions (no resize). final probeCodec = await ui.instantiateImageCodec(rawBytes); final probeFrame = await probeCodec.getNextFrame(); @@ -35,13 +45,19 @@ class ImageCodecService { // the image fits within maxDimension×maxDimension without stretching. int dstW = srcW; int dstH = srcH; - if (srcW > maxDimension || srcH > maxDimension) { + if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) { if (srcW >= srcH) { - dstW = maxDimension; - dstH = (srcH * maxDimension / srcW).round().clamp(1, maxDimension); + dstW = effectiveMaxDimension; + dstH = (srcH * effectiveMaxDimension / srcW).round().clamp( + 1, + effectiveMaxDimension, + ); } else { - dstH = maxDimension; - dstW = (srcW * maxDimension / srcH).round().clamp(1, maxDimension); + dstH = effectiveMaxDimension; + dstW = (srcW * effectiveMaxDimension / srcH).round().clamp( + 1, + effectiveMaxDimension, + ); } } @@ -67,7 +83,7 @@ class ImageCodecService { // 3. Optionally convert to grayscale in-place (luminance). final rgba = byteData.buffer.asUint8List(); - if (grayscale) { + if (forceGrayscale) { 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]) @@ -102,13 +118,19 @@ class ImageCodecService { // 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 maxQ = ((effectiveCompression / 100) * 63).round().clamp(0, 63); final minQ = (maxQ * 0.65).round().clamp(0, maxQ); final avif = await encodeAvif( pngBytes, + maxThreads: 2, maxQuantizer: maxQ, minQuantizer: minQ, - speed: 8, // fast encode (0 = slowest/best, 10 = fastest) + // We force fully opaque alpha, so alpha can be quantized aggressively. + maxQuantizerAlpha: 63, + minQuantizerAlpha: 63, + // Slower speed improves compression efficiency at similar quality. + speed: ultraMode ? _ultraAvifSpeed : _normalAvifSpeed, + keepExif: false, ); if (avif.isEmpty) return null; diff --git a/lib/services/image_preferences.dart b/lib/services/image_preferences.dart index f883b44..f04cb2e 100644 --- a/lib/services/image_preferences.dart +++ b/lib/services/image_preferences.dart @@ -6,12 +6,14 @@ class ImagePreferences { // Keep the legacy key name so existing users retain their saved value. static const String _qualityKey = 'image_quality'; static const String _grayscaleKey = 'image_grayscale'; + static const String _ultraModeKey = 'image_ultra_mode'; static const int defaultMaxSize = 256; static const int defaultQuality = 90; static const bool defaultGrayscale = true; + static const bool defaultUltraMode = false; - static const List supportedSizes = [64, 128, 256]; + static const List supportedSizes = [64, 96, 128, 256]; static Future getMaxSize() async { final prefs = await SharedPreferences.getInstance(); @@ -44,4 +46,21 @@ class ImagePreferences { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_grayscaleKey, value); } + + static Future getUltraMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_ultraModeKey) ?? defaultUltraMode; + } + + static Future setUltraMode(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_ultraModeKey, value); + } + + static int effectiveMaxSize( + int configuredMaxSize, { + required bool ultraMode, + }) { + return configuredMaxSize.clamp(32, 1024); + } } diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart index fd5b773..4d00fa6 100644 --- a/lib/utils/image_message_parser.dart +++ b/lib/utils/image_message_parser.dart @@ -3,6 +3,15 @@ import 'dart:typed_data'; const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD +const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload +const int _imagePacketHeaderBytes = 8; // image packet binary header in payload +const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) +const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) +const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle /// Compressed image format used in the image packet protocol. enum ImageFormat { @@ -119,6 +128,118 @@ int safeImageDataBytesForPath(int pathLen) { return maxData.clamp(1, 255).toInt(); } +/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa. +/// +/// The estimate uses: +/// - LoRa airtime math with MeshCore companion defaults (SF10/BW250/CR5), +/// - MeshCore airtime budget pacing (default factor 1.0), +/// - hop multiplier (`pathLen + 1`) for direct routed packets. +Duration estimateImageTransmitDuration({ + required int fragmentCount, + required int sizeBytes, + int pathLen = 0, + int? radioBw, + int? radioSf, + int? radioCr, +}) { + if (fragmentCount <= 0 || sizeBytes <= 0) return Duration.zero; + + final safePathLen = pathLen.clamp(0, 64); + final hops = safePathLen + 1; + final baseDataPerFragment = sizeBytes ~/ fragmentCount; + final extraBytes = sizeBytes % fragmentCount; + var totalMs = 0.0; + + for (var i = 0; i < fragmentCount; i++) { + final fragmentDataBytes = baseDataPerFragment + (i < extraBytes ? 1 : 0); + final loraLen = + _meshPacketHeaderBytes + + safePathLen + + _imagePacketHeaderBytes + + fragmentDataBytes; + final airtimeMs = _estimateLoRaAirtimeMs( + loraLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; + } + + return Duration(milliseconds: totalMs.round()); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} + /// Envelope announcing image availability (control plane). /// /// Text format: diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index cb59a5f..24346e0 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -1,6 +1,16 @@ import 'dart:convert'; import 'dart:typed_data'; +const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload +const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload +const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) +const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) +const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle + /// Identifies which Codec2 mode was used for a voice packet. /// Matches the modeId byte in the text/binary packet header. enum VoicePacketMode { @@ -248,6 +258,160 @@ class VoiceEnvelope { } } +int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { + VoicePacketMode.mode700c => 100, + VoicePacketMode.mode1200 => 150, + VoicePacketMode.mode1300 => 175, + VoicePacketMode.mode1400 => 175, + VoicePacketMode.mode1600 => 200, + VoicePacketMode.mode2400 => 300, + VoicePacketMode.mode3200 => 400, +}; + +/// Approximate end-to-end transmit time for a voice session over MeshCore LoRa. +/// +/// Uses envelope-level metadata (mode + duration + packet count) when only the +/// envelope is available and packet bytes are not yet received locally. +Duration estimateVoiceTransmitDuration({ + required VoicePacketMode mode, + required int packetCount, + required int durationMs, + int pathLen = 0, + int? radioBw, + int? radioSf, + int? radioCr, +}) { + if (packetCount <= 0 || durationMs <= 0) return Duration.zero; + + final bytesPerSecond = voiceModeBytesPerSecond(mode); + final totalCodecBytes = (durationMs * bytesPerSecond / 1000.0).round(); + final safePathLen = pathLen.clamp(0, 64); + final hops = safePathLen + 1; + final baseBytesPerPacket = totalCodecBytes ~/ packetCount; + final extraBytes = totalCodecBytes % packetCount; + var totalMs = 0.0; + + for (var i = 0; i < packetCount; i++) { + final codecBytes = baseBytesPerPacket + (i < extraBytes ? 1 : 0); + final loraLen = + _meshPacketHeaderBytes + + safePathLen + + _voicePacketHeaderBytes + + codecBytes; + final airtimeMs = _estimateLoRaAirtimeMs( + loraLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; + } + + return Duration(milliseconds: totalMs.round()); +} + +/// Approximate transmit time using actually received voice packet sizes. +Duration estimateVoiceTransmitDurationFromPackets({ + required Iterable packets, + int pathLen = 0, + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final safePathLen = pathLen.clamp(0, 64); + final hops = safePathLen + 1; + var totalMs = 0.0; + + for (final packet in packets) { + if (packet == null) continue; + final loraLen = + _meshPacketHeaderBytes + + safePathLen + + _voicePacketHeaderBytes + + packet.codec2Data.length; + final airtimeMs = _estimateLoRaAirtimeMs( + loraLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; + } + + return Duration(milliseconds: totalMs.round()); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} + /// Direct control-plane request to fetch voice packets for a session. /// /// Text format: diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 838caa0..dce6a59 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -34,6 +34,15 @@ class _ImageMessageBubbleState extends State { @override Widget build(BuildContext context) { + final radioBw = context.select( + (p) => p.deviceInfo.radioBw, + ); + final radioSf = context.select( + (p) => p.deviceInfo.radioSf, + ); + final radioCr = context.select( + (p) => p.deviceInfo.radioCr, + ); final envelope = ImageEnvelope.tryParse(widget.message.text); if (envelope == null) return const SizedBox.shrink(); @@ -84,6 +93,10 @@ class _ImageMessageBubbleState extends State { received: received, total: total, envelope: envelope, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, error: _errorText, isSentByMe: widget.isSentByMe, ), @@ -219,17 +232,40 @@ class _ImageMessageBubbleState extends State { required int received, required int total, required ImageEnvelope envelope, + required int pathLen, + required int? radioBw, + required int? radioSf, + required int? radioCr, required String? error, required bool isSentByMe, }) { + final txEstimate = estimateImageTransmitDuration( + fragmentCount: envelope.total, + sizeBytes: envelope.sizeBytes, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final txEstimateLabel = _formatTransmitEstimate(txEstimate); + if (error != null) return error; - if (isRequesting) return '📥 Loading… $received/$total'; + if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel'; if (isComplete) { final base = '🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}'; - return isSentByMe ? '$base · ${envelope.total} seg' : base; + return isSentByMe + ? '$base · ${envelope.total} seg · $txEstimateLabel' + : '$base · $txEstimateLabel'; } - return '🖼️ Tap to load · ${envelope.width}×${envelope.height}'; + return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel'; + } + + static String _formatTransmitEstimate(Duration value) { + if (value.inSeconds < 60) return '~${value.inSeconds}s tx'; + final minutes = value.inMinutes; + final seconds = value.inSeconds % 60; + return '~${minutes}m ${seconds}s tx'; } void _showFullScreen(BuildContext context, Uint8List imageBytes) { diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 4d23f0d..3276f74 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -298,6 +298,9 @@ class _MessageBubbleState extends State { void _showTechnicalDetails(BuildContext context) { final connectionProvider = context.read(); + final radioBw = connectionProvider.deviceInfo.radioBw; + final radioSf = connectionProvider.deviceInfo.radioSf; + final radioCr = connectionProvider.deviceInfo.radioCr; final contactsProvider = context.read(); final voiceProvider = context.read(); final imageProvider = context.read(); @@ -350,6 +353,45 @@ class _MessageBubbleState extends State { final imageSession = imageEnvelope != null ? imageProvider.session(imageEnvelope.sessionId) : null; + final imageTxEstimate = imageEnvelope != null + ? estimateImageTransmitDuration( + fragmentCount: imageEnvelope.total, + sizeBytes: imageEnvelope.sizeBytes, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : Duration.zero; + final voiceTxEstimate = voiceSession != null + ? estimateVoiceTransmitDurationFromPackets( + packets: voiceSession.packets, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : envelope != null + ? estimateVoiceTransmitDuration( + mode: envelope.mode, + packetCount: envelope.total, + durationMs: envelope.durationMs, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : legacyVoicePacket != null + ? estimateVoiceTransmitDuration( + mode: legacyVoicePacket.mode, + packetCount: legacyVoicePacket.total, + durationMs: legacyVoicePacket.durationMs * legacyVoicePacket.total, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : Duration.zero; final senderPrefixHex = widget.message.senderPublicKeyPrefix ?.map((b) => b.toRadixString(16).padLeft(2, '0')) @@ -431,8 +473,16 @@ class _MessageBubbleState extends State { rawLines.add( 'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}', ); + rawLines.add( + 'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)', + ); } else { rawLines.add('Session present locally: no'); + if (voiceTxEstimate > Duration.zero) { + rawLines.add( + 'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)', + ); + } } } @@ -448,6 +498,9 @@ class _MessageBubbleState extends State { ); rawLines.add('Fragments total (envelope): ${imageEnvelope.total}'); rawLines.add('Compressed size (envelope): ${imageEnvelope.sizeBytes} B'); + rawLines.add( + 'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)', + ); rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}'); rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}'); rawLines.add('Envelope ver: ${imageEnvelope.version}'); @@ -588,7 +641,9 @@ class _MessageBubbleState extends State { _detailRow( context, label: l10n.floodFallback, - value: widget.message.usedFloodFallback ? l10n.yes : l10n.no, + value: widget.message.usedFloodFallback + ? l10n.yes + : l10n.no, ), ], ), @@ -670,6 +725,59 @@ class _MessageBubbleState extends State { label: l10n.complete, value: voiceSession.isComplete ? l10n.yes : l10n.no, ), + if (voiceTxEstimate > Duration.zero) + _detailRow( + context, + label: 'Estimated tx', + value: voiceTxEstimate.inSeconds < 60 + ? '~${voiceTxEstimate.inSeconds}s' + : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', + ), + ], + ), + ), + ], + if (imageEnvelope != null) ...[ + const SizedBox(height: 12), + _techSection( + context, + icon: Icons.image_outlined, + title: 'Image', + child: Column( + children: [ + _detailRow(context, label: l10n.envelope, value: 'IE1'), + _detailRow( + context, + label: 'Format', + value: imageEnvelope.format.label, + ), + _detailRow( + context, + label: 'Dimensions', + value: + '${imageEnvelope.width}×${imageEnvelope.height}', + ), + _detailRow( + context, + label: 'Segments', + value: imageSession != null + ? '${imageSession.receivedCount}/${imageSession.total}' + : '${imageEnvelope.total}', + ), + if (imageSession != null) + _detailRow( + context, + label: l10n.complete, + value: imageSession.isComplete ? l10n.yes : l10n.no, + ), + if (imageTxEstimate > Duration.zero) + _detailRow( + context, + label: 'Estimated tx', + value: imageTxEstimate.inSeconds < 60 + ? '~${imageTxEstimate.inSeconds}s' + : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', + ), ], ), ), @@ -681,7 +789,10 @@ class _MessageBubbleState extends State { visualDensity: VisualDensity.compact, title: Text( l10n.rawDump, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), ), children: [ Container( @@ -1834,7 +1945,8 @@ class _MessageBubbleState extends State { !widget.isCompact) VoiceMessageBubble(message: message, isSentByMe: isOwnMessage) // Image message content (IE1 envelope) - else if (ImageEnvelope.isEnvelope(message.text) && !widget.isCompact) + else if (ImageEnvelope.isEnvelope(message.text) && + !widget.isCompact) ImageMessageBubble(message: message, isSentByMe: isOwnMessage) // Regular message content else if (!message.isDrawing || widget.isCompact) diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index ae0e040..cd131c3 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -31,6 +31,15 @@ class _VoiceMessageBubbleState extends State { @override Widget build(BuildContext context) { + final radioBw = context.select( + (p) => p.deviceInfo.radioBw, + ); + final radioSf = context.select( + (p) => p.deviceInfo.radioSf, + ); + final radioCr = context.select( + (p) => p.deviceInfo.radioCr, + ); final voiceId = widget.message.voiceId; if (voiceId == null) return const SizedBox.shrink(); @@ -73,6 +82,16 @@ class _VoiceMessageBubbleState extends State { session: session, messageText: widget.message.text, ); + final txEstimate = _resolveVoiceTransmitEstimate( + session: session, + envelope: envelope, + messageText: widget.message.text, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final txEstimateLabel = _formatTransmitEstimate(txEstimate); return Row( mainAxisSize: MainAxisSize.min, @@ -124,21 +143,21 @@ class _VoiceMessageBubbleState extends State { ), ) else - _WaveformBar( - isComplete: isComplete, - bars: waveformBars, - ), + _WaveformBar(isComplete: isComplete, bars: waveformBars), const SizedBox(height: 4), Text( _buildStatusText( durationLabel: durationLabel, modeLabel: modeLabel, + txEstimateLabel: txEstimateLabel, received: received, total: total, isComplete: isComplete, isRequesting: _isRequesting, errorText: _errorText, - requestingLabel: AppLocalizations.of(context)!.requestingVoice, + requestingLabel: AppLocalizations.of( + context, + )!.requestingVoice, ), style: TextStyle( fontSize: 11, @@ -236,6 +255,7 @@ class _VoiceMessageBubbleState extends State { static String _buildStatusText({ required String durationLabel, required String modeLabel, + required String txEstimateLabel, required int received, required int total, required bool isComplete, @@ -246,12 +266,12 @@ class _VoiceMessageBubbleState extends State { if (errorText != null) return errorText; final progress = total > 0 ? ' ($received/$total)' : ''; if (isRequesting) { - return '$requestingLabel$progress'; + return '$requestingLabel$progress · $txEstimateLabel'; } if (!isComplete && total > 0) { - return '🎙️ $durationLabel · $modeLabel$progress'; + return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel'; } - return '🎙️ $durationLabel · $modeLabel'; + return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel'; } List _resolveWaveformBars({ @@ -271,16 +291,68 @@ class _VoiceMessageBubbleState extends State { return const []; } + + Duration _resolveVoiceTransmitEstimate({ + required VoiceSession? session, + required VoiceEnvelope? envelope, + required String messageText, + required int pathLen, + required int? radioBw, + required int? radioSf, + required int? radioCr, + }) { + if (session != null) { + final fromSession = estimateVoiceTransmitDurationFromPackets( + packets: session.packets, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + if (fromSession > Duration.zero) return fromSession; + } + + if (envelope != null) { + return estimateVoiceTransmitDuration( + mode: envelope.mode, + packetCount: envelope.total, + durationMs: envelope.durationMs, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final legacyPacket = VoicePacket.tryParseText(messageText); + if (legacyPacket != null) { + return estimateVoiceTransmitDuration( + mode: legacyPacket.mode, + packetCount: legacyPacket.total, + durationMs: legacyPacket.durationMs * legacyPacket.total, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + return Duration.zero; + } + + static String _formatTransmitEstimate(Duration value) { + if (value <= Duration.zero) return '~0s tx'; + if (value.inSeconds < 60) return '~${value.inSeconds}s tx'; + final minutes = value.inMinutes; + final seconds = value.inSeconds % 60; + return '~${minutes}m ${seconds}s tx'; + } } /// Voice waveform rendered as a row of bars. class _WaveformBar extends StatelessWidget { final bool isComplete; final List bars; - const _WaveformBar({ - required this.isComplete, - required this.bars, - }); + const _WaveformBar({required this.isComplete, required this.bars}); @override Widget build(BuildContext context) { diff --git a/pubspec.yaml b/pubspec.yaml index b41d68f..cdc1e11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -173,10 +173,8 @@ flutter: # Enable generation of localization files generate: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - screenshots/ios/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images