Tweak AVIF tuning and preferences

This commit is contained in:
Janez T
2026-03-02 09:42:14 +01:00
parent 4d0c45c11c
commit c176bd92a9
12 changed files with 972 additions and 47 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -450,11 +450,17 @@ class _MessagesTabState extends State<MessagesTab> {
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;

View File

@@ -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<SettingsScreen> {
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<SettingsScreen> {
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<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size);
if (!mounted) return;
setState(() => _imageMaxSize = size);
await _refreshImageModePreview();
}
Future<void> _saveImageCompression(int compression) async {
await ImagePreferences.setCompression(compression);
if (!mounted) return;
setState(() => _imageCompression = compression);
await _refreshImageModePreview();
}
Future<void> _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<void> _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<void> _initializeLocationService() async {
@@ -606,14 +685,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Voice Settings Section
_buildSectionHeader('Voice'),
Consumer<AppProvider>(
builder: (context, appProvider, child) => _buildVoiceStatsCard(
bitrate: _voiceBitrate,
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
Consumer2<AppProvider, ConnectionProvider>(
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<SettingsScreen> {
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<ConnectionProvider>(
builder: (context, connectionProvider, child) =>
_buildImageModePreviewCard(connectionProvider),
),
const Divider(),
// Templates Section
@@ -931,8 +1032,187 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
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<SettingsScreen> {
(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<SettingsScreen> {
'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),

View File

@@ -9,6 +9,9 @@ import 'package:flutter_avif/flutter_avif.dart';
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
/// → 720 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;

View File

@@ -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<int> supportedSizes = [64, 128, 256];
static const List<int> supportedSizes = [64, 96, 128, 256];
static Future<int> 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<bool> getUltraMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_ultraModeKey) ?? defaultUltraMode;
}
static Future<void> 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);
}
}

View File

@@ -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:

View File

@@ -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<VoicePacket?> 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:

View File

@@ -34,6 +34,15 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
@override
Widget build(BuildContext context) {
final radioBw = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioBw,
);
final radioSf = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioSf,
);
final radioCr = context.select<ConnectionProvider, int?>(
(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<ImageMessageBubble> {
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<ImageMessageBubble> {
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) {

View File

@@ -298,6 +298,9 @@ class _MessageBubbleState extends State<MessageBubble> {
void _showTechnicalDetails(BuildContext context) {
final connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf;
final radioCr = connectionProvider.deviceInfo.radioCr;
final contactsProvider = context.read<ContactsProvider>();
final voiceProvider = context.read<VoiceProvider>();
final imageProvider = context.read<ip.ImageProvider>();
@@ -350,6 +353,45 @@ class _MessageBubbleState extends State<MessageBubble> {
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<MessageBubble> {
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<MessageBubble> {
);
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<MessageBubble> {
_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<MessageBubble> {
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<MessageBubble> {
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<MessageBubble> {
!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)

View File

@@ -31,6 +31,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
@override
Widget build(BuildContext context) {
final radioBw = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioBw,
);
final radioSf = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioSf,
);
final radioCr = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioCr,
);
final voiceId = widget.message.voiceId;
if (voiceId == null) return const SizedBox.shrink();
@@ -73,6 +82,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
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<VoiceMessageBubble> {
),
)
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<VoiceMessageBubble> {
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<VoiceMessageBubble> {
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<double> _resolveWaveformBars({
@@ -271,16 +291,68 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
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<double> bars;
const _WaveformBar({
required this.isComplete,
required this.bars,
});
const _WaveformBar({required this.isComplete, required this.bars});
@override
Widget build(BuildContext context) {

View File

@@ -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