Add ability to send images over lora

This commit is contained in:
Zach
2026-08-10 23:20:46 -07:00
parent 736eb064bf
commit eee72b4c65
129 changed files with 27099 additions and 58 deletions
+23 -3
View File
@@ -43,9 +43,22 @@ android {
// arguments += listOf("-DANDROID_STL=c++_shared") // arguments += listOf("-DANDROID_STL=c++_shared")
// } // }
// } // }
// ndk { // arm64-v8a only, deliberately.
// abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64") //
// } // * ONNX Runtime (flutter_onnxruntime, used by the AEIC-SE image codec)
// ships a per-ABI .so. arm64-v8a alone costs ~18 MB of APK; a
// universal APK carrying armeabi-v7a and x86_64 as well costs ~56 MB.
// * llamadart only declares android-arm64 and android-x64 backends in
// pubspec.yaml's `hooks.user_defines`, so an armeabi-v7a build already
// has no translation backend at all.
// * The image codec needs ~2.7 GiB peak resident, which no 32-bit
// address space can provide regardless of ABI.
//
// Consequence: this APK will not install on 32-bit-only ARM devices or on
// x86_64 emulators. For emulator work, temporarily add "x86_64" here.
ndk {
abiFilters += listOf("arm64-v8a")
}
} }
signingConfigs { signingConfigs {
@@ -67,6 +80,13 @@ android {
} else { } else {
signingConfigs.getByName("debug") signingConfigs.getByName("debug")
} }
// ONNX Runtime resolves its Java classes from native code by name.
// Without these rules R8 renames them and the process SIGABRTs with
// "java_class == null" the instant the codec runs a model.
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
} }
} }
+19
View File
@@ -0,0 +1,19 @@
# ONNX Runtime looks its Java classes up from native code with FindClass /
# GetMethodID, by literal name. R8 renames them, the lookup returns null, and
# the process dies with:
#
# JNI DETECTED ERROR IN APPLICATION: java_class == null
# at art::JNI<false>::GetMethodID
# from convertToTensorInfo -> Java_ai_onnxruntime_OrtSession_run
#
# It is a hard SIGABRT in native code, so nothing in Dart can catch it: the app
# vanishes the moment the codec touches the model. Keep the whole package
# these are the types the native layer reflects on to build tensors and read
# results back.
-keep class ai.onnxruntime.** { *; }
-keepclassmembers class ai.onnxruntime.** { *; }
-dontwarn ai.onnxruntime.**
# The Flutter plugin's platform-channel handler, reached the same way.
-keep class com.masicai.flutteronnxruntime.** { *; }
-dontwarn com.masicai.flutteronnxruntime.**
+2
View File
@@ -37,6 +37,8 @@
<string>This app uses Bluetooth to communicate with MeshCore devices.</string> <string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>This app uses the camera to scan QR codes for joining communities.</string> <string>This app uses the camera to scan QR codes for joining communities.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app lets you pick a photo to compress and send over the mesh.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
+158
View File
@@ -24,6 +24,16 @@ import '../services/ble_debug_log_service.dart';
import '../services/linux_ble_error_classifier.dart'; import '../services/linux_ble_error_classifier.dart';
import '../services/linux_ble_pairing_service_stub.dart' import '../services/linux_ble_pairing_service_stub.dart'
if (dart.library.io) '../services/linux_ble_pairing_service.dart'; if (dart.library.io) '../services/linux_ble_pairing_service.dart';
import '../services/image_chunk_transport.dart'
show
ImageChunkOutcome,
ImageChunkTransport,
buildSendChannelDataFrame,
dataTypeAeicImage,
outPathUnknown,
respCodeChannelDataRecv,
senderPrefixFromKey;
import '../services/image_codec_service.dart';
import '../services/message_retry_service.dart'; import '../services/message_retry_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
@@ -248,6 +258,13 @@ class MeshCoreConnector extends ChangeNotifier {
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService; TimeoutPredictionService? _timeoutPredictionService;
TranslationService? _translationService; TranslationService? _translationService;
ImageCodecService? _imageCodecService;
// Holds the ImageReassembler, so partially received images survive across
// frames (chunks of one image arrive in separate GRP_DATA frames, and via the
// firmware offline queue they may be minutes apart).
ImageChunkTransport? _imageTransport;
void Function(ImageChunkOutcome outcome)? _onImageChunk;
void Function(int senderPrefix)? _onImageSenderPrefix;
// Intentionally global (not per-contact): tracks overall network activity. // Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions. // Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now(); DateTime _lastRxTime = DateTime.now();
@@ -460,6 +477,11 @@ class MeshCoreConnector extends ChangeNotifier {
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey; Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
/// First 2 bytes of the local public key, big-endian: the `senderPrefix`
/// stamped into every outgoing image chunk header. Null until SELF_INFO.
int? get imageSenderPrefix => senderPrefixFromKey(_selfPublicKey);
String? get selfName => _selfName; String? get selfName => _selfName;
String? get manufacturerName => _manufacturerName; String? get manufacturerName => _manufacturerName;
String? get firmwareVersionString => _firmwareVersionString; String? get firmwareVersionString => _firmwareVersionString;
@@ -996,11 +1018,19 @@ class MeshCoreConnector extends ChangeNotifier {
AppDebugLogService? appDebugLogService, AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService, BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService, TimeoutPredictionService? timeoutPredictionService,
ImageCodecService? imageCodecService,
ImageChunkTransport? imageTransport,
void Function(ImageChunkOutcome outcome)? onImageChunk,
void Function(int senderPrefix)? onImageSenderPrefix,
}) { }) {
_retryService = retryService; _retryService = retryService;
_pathHistoryService = pathHistoryService; _pathHistoryService = pathHistoryService;
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_translationService = translationService; _translationService = translationService;
_imageCodecService = imageCodecService;
_imageTransport = imageTransport;
_onImageChunk = onImageChunk;
_onImageSenderPrefix = onImageSenderPrefix;
_bleDebugLogService = bleDebugLogService; _bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService; _appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
@@ -2575,6 +2605,8 @@ class MeshCoreConnector extends ChangeNotifier {
void _resetConnectionHandshakeState() { void _resetConnectionHandshakeState() {
_selfPublicKey = null; _selfPublicKey = null;
// Partially received images belong to the previous session's sender prefix.
_imageTransport?.reassembler.clear();
_selfName = null; _selfName = null;
_selfLatitude = null; _selfLatitude = null;
_selfLongitude = null; _selfLongitude = null;
@@ -2718,6 +2750,19 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncTimeout = null; _channelSyncTimeout = null;
_channelSyncRetries = 0; _channelSyncRetries = 0;
await _translationService?.releaseModel(); await _translationService?.releaseModel();
// The decode graph is ~833 MB resident; drop it with the connection. The
// connector never disposes services it does not own (main.dart does).
//
// Cancel first: `releaseModel` serialises behind the codec's exclusive
// lock, so disconnecting mid-decode would otherwise block this teardown
// for the ~1 s the inference takes and then tear the session out from
// under a job whose result nobody is waiting for any more.
_imageCodecService?.cancelCodecJob();
await _imageCodecService?.releaseModel();
// Partial images belong to this session's sender prefixes; a reconnect
// starts over. Dropping them also stops `evictExpired` from firing "image
// incomplete" failures into the store minutes after the radio went away.
_imageTransport?.reassembler.clear();
if (!skipBleDeviceDisconnect) { if (!skipBleDeviceDisconnect) {
try { try {
@@ -3550,6 +3595,77 @@ class MeshCoreConnector extends ChangeNotifier {
}, region: getChannelRegion(channel.index)); }, region: getChannelRegion(channel.index));
} }
/// Minimum companion firmware version code that implements
/// CMD_SEND_CHANNEL_DATA (62) / RESP_CODE_CHANNEL_DATA_RECV (27).
static const int _minFirmwareVerCodeForChannelData = 13;
/// True when the connected device can send/receive GRP_DATA blobs.
bool get supportsChannelData =>
isConnected &&
(_firmwareVerCode ?? 0) >= _minFirmwareVerCodeForChannelData;
/// Sends one image's chunk [blobs] as GRP_DATA packets on [channelIndex],
/// strictly sequentially, inside a single scoped-send scope for the whole
/// image (the flood scope is global, so re-entering it per chunk would let
/// another channel's send interleave mid-image).
///
/// [onProgress] fires after each chunk is acked, so the UI can show
/// "sending 2 of 3".
///
/// IMPORTANT: the command ACK only proves the firmware accepted the blob and
/// queued a packet to the radio. It says nothing about transmission or
/// delivery — GRP_DATA is unacknowledged flood traffic, there is no
/// PUSH_CODE_SEND_CONFIRMED for it, and a chunk may still be lost on air.
/// That is what the XOR parity chunk is for.
///
/// Returns false when the transport is unavailable (disconnected or firmware
/// too old); throws whatever [_sendFrameAndWaitForCommandAck] throws, i.e.
/// Exception('Command failed with error code N') where N is 1
/// (unsupported command), 2 (bad channel index), 3 (packet pool full, worth
/// retrying) or 4 (illegal arg — blob too long), or a TimeoutException after
/// _commandAckTimeout.
Future<bool> sendImageChunks(
List<Uint8List> blobs, {
required int channelIndex,
required Duration interChunkDelay,
void Function(int sent, int total)? onProgress,
}) async {
if (blobs.isEmpty) return false;
if (!supportsChannelData) return false;
// `return` inside the scoped callback only exits the callback, so a
// disconnect mid-send used to fall through to `return true` below and the
// caller would record a complete outgoing image and tell the user "sent"
// after transmitting a prefix of the chunks. Track it explicitly.
var sentAll = true;
await _runScopedChannelSend(() async {
for (var i = 0; i < blobs.length; i++) {
if (!isConnected) {
sentAll = false;
return;
}
if (i > 0) await Future<void>.delayed(interChunkDelay);
// Same collision avoidance as every other channel send. On a quiet mesh
// this returns almost immediately; interChunkDelay is what actually
// paces the chunks so we do not talk over our own previous packet.
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
await _sendFrameAndWaitForCommandAck(
buildSendChannelDataFrame(
channelIndex: channelIndex,
dataType: dataTypeAeicImage,
payload: blobs[i],
pathLen: outPathUnknown, // 0xFF => flood
),
// Code 62 must not be enrolled in the generic-ack queue, and the
// firmware replies with RESP_CODE_OK (writeOKFrame), not RESP_CODE_SENT.
expectsGenericAck: false,
successCode: respCodeOk,
);
onProgress?.call(i + 1, blobs.length);
}
}, region: getChannelRegion(channelIndex));
return sentAll;
}
Future<void> _runScopedChannelSend( Future<void> _runScopedChannelSend(
Future<void> Function() action, { Future<void> Function() action, {
required String region, required String region,
@@ -4231,6 +4347,27 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeChannelMsgRecvV3: case respCodeChannelMsgRecvV3:
_handleIncomingChannelMessage(frame); _handleIncomingChannelMessage(frame);
break; break;
case respCodeChannelDataRecv:
// RESP_CODE 27 (GRP_DATA received) is delivered through the firmware
// offline queue in reply to CMD_SYNC_NEXT_MESSAGE, so the queued-message
// sync state machine MUST be advanced here — exactly like
// _handleIncomingChannelMessage does. Leaving code 27 unhandled stalls
// _queueSyncTimeout (5 s) x _maxQueueSyncRetries (3) per chunk and
// blocks every other queued message behind it. A receivedFrames
// listener cannot do this: the sync state is private. Advance exactly
// ONCE, before any async work.
if (_isSyncingQueuedMessages) {
_handleQueuedMessageReceived();
}
// Advance the sync state machine ALWAYS (above), but only reassemble
// when the feature is actually on. Without this the "disabled" feature
// still reassembles, persists and displays received images: the store
// keeps them and channel_chat_screen renders every stored entry, so a
// user who never enabled image messages would see them appear.
if (_appSettingsService?.settings.imageMessagesEnabled ?? false) {
_handleIncomingChannelData(frame);
}
break;
case respCodeSent: case respCodeSent:
_handleMessageSent(frame); _handleMessageSent(frame);
break; break;
@@ -4369,6 +4506,14 @@ class MeshCoreConnector extends ChangeNotifier {
_currentCr = reader.readByte(); _currentCr = reader.readByte();
_selfName = reader.readCString(); _selfName = reader.readCString();
// The local public key only exists once SELF_INFO has arrived, so the
// image sender prefix (first 2 bytes, big-endian) is refreshed here.
final prefix = senderPrefixFromKey(_selfPublicKey);
if (prefix != null) {
_imageTransport?.senderPrefix = prefix;
_onImageSenderPrefix?.call(prefix);
}
} catch (e) { } catch (e) {
_appDebugLogService?.error( _appDebugLogService?.error(
'Error parsing SELF_INFO frame: $e', 'Error parsing SELF_INFO frame: $e',
@@ -5493,6 +5638,19 @@ class MeshCoreConnector extends ChangeNotifier {
}()); }());
} }
/// Handles RESP_CODE_CHANNEL_DATA_RECV (GRP_DATA). Frames whose dataType is
/// not 0xAE1C — or that arrive before an [ImageChunkTransport] is installed —
/// are ignored without disturbing any other behaviour.
void _handleIncomingChannelData(Uint8List frame) {
// Anchor the post-RX backoff for our own next channel send, exactly as
// _handleIncomingChannelMessage does.
_lastChannelMsgRxTime = DateTime.now();
final outcome = _imageTransport?.handleFrame(frame);
if (outcome == null) return; // Not an AEIC image chunk.
_onImageChunk?.call(outcome);
notifyListeners();
}
void _handleIncomingChannelMessage(Uint8List frame) async { void _handleIncomingChannelMessage(Uint8List frame) async {
// If we're syncing the queued messages, advance the queue immediately // If we're syncing the queued messages, advance the queue immediately
// before any potentially long async work (like translation/notifications). // before any potentially long async work (like translation/notifications).
+78
View File
@@ -0,0 +1,78 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../l10n/l10n.dart';
import '../widgets/image_send_codec_binding.dart';
import '../widgets/image_send_preview_sheet.dart';
/// Shared "attach an image" flow, used by both the direct-message and channel
/// chat screens so the two surfaces cannot drift apart.
///
/// Picks a photo, then shows [ImageSendPreviewSheet] so the user sees the
/// packet count and airtime *before* committing to a send that may occupy the
/// channel for seconds. Returns null if the user backed out at either step.
///
/// The caller owns the actual transmission: this only produces the payload.
Future<ImageSendPreviewResult?> pickAndPreviewImage({
required BuildContext context,
required ImageSendCodec codec,
ImagePicker? picker,
}) async {
final XFile? picked;
try {
picked = await (picker ?? ImagePicker()).pickImage(
source: ImageSource.gallery,
// The codec centre-crops to 512x512 anyway, so there is nothing to gain
// from decoding a 12 MP original -- but stay well above 512 so the crop
// still has detail to work with.
maxWidth: 2048,
maxHeight: 2048,
);
} on Exception catch (e) {
debugPrint('image pick failed: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
);
}
return null;
}
if (picked == null) return null; // user cancelled the picker
final Uint8List bytes;
int originalBytes;
try {
bytes = await picked.readAsBytes();
originalBytes = bytes.length;
if (!kIsWeb) {
// length() is the on-disk size, which is what we want to show against the
// transmitted size; fall back to the in-memory length if it fails.
try {
originalBytes = await File(picked.path).length();
} on FileSystemException {
// keep the in-memory length
}
}
} on Exception catch (e) {
debugPrint('image read failed: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
);
}
return null;
}
if (!context.mounted) return null;
return showImageSendPreviewSheet(
context: context,
imageBytes: bytes,
originalFileBytes: originalBytes,
codec: codec,
);
}
+167 -1
View File
@@ -741,6 +741,11 @@
} }
}, },
"chat_sendGif": "Send GIF", "chat_sendGif": "Send GIF",
"chat_sendImage": "Send image",
"chat_imagePickFailed": "Couldn't open that image",
"@chat_imagePickFailed": {
"description": "Shown when picking or reading a photo to send fails"
},
"chat_receivedGif": "Received a GIF", "chat_receivedGif": "Received a GIF",
"chat_reply": "Reply", "chat_reply": "Reply",
"chat_addReaction": "Add Reaction", "chat_addReaction": "Add Reaction",
@@ -2535,6 +2540,17 @@
"radioStats_settingsTile": "Radio stats", "radioStats_settingsTile": "Radio stats",
"radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime", "radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime",
"translation_title": "Translation", "translation_title": "Translation",
"imageMessages_enableTitle": "Enable image messages",
"imageMessages_enableSubtitle": "Send images over the mesh. Requires a one-time image model download.",
"imageMessages_modelSectionTitle": "Image model",
"imageMessages_downloadModel": "Download",
"imageMessages_cancelDownload": "Cancel",
"imageMessages_removeModel": "Remove model",
"imageMessages_modelReady": "Ready",
"imageMessages_modelNotPublished": "Not published yet — this build cannot download it.",
"imageMessages_downloadFailed": "The image model could not be downloaded.",
"imageMessages_autoProcessTitle": "Process images automatically",
"imageMessages_autoProcessSubtitle": "Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.",
"translation_enableTitle": "Enable translation", "translation_enableTitle": "Enable translation",
"translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.", "translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.",
"translation_composerTitle": "Translate before sending", "translation_composerTitle": "Translate before sending",
@@ -2716,5 +2732,155 @@
"pathMap_expandPanel": "Expand panel", "pathMap_expandPanel": "Expand panel",
"pathMap_noLocation": "No location", "pathMap_noLocation": "No location",
"pathMap_followPacket": "Lock view to packet", "pathMap_followPacket": "Lock view to packet",
"pathMap_unfollowPacket": "Unlock view from packet" "pathMap_unfollowPacket": "Unlock view from packet",
"imageSend_title": "Send image",
"imageSend_cropNote": "Resized to 512 × 512 · aspect ratio not preserved",
"imageSend_originalSize": "Original",
"imageSend_onAirSize": "On air",
"imageSend_quality": "Quality",
"imageSend_qualityStandard": "Standard",
"imageSend_qualityHigh": "High",
"imageSend_packetsLabel": "Packets",
"imageSend_airtimeLabel": "Time on air",
"imageSend_sizeLabel": "Payload",
"imageSend_packetsCount": "{count} {count, plural, =1{packet} other{packets}}",
"@imageSend_packetsCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"imageSend_range": "{min}{max}",
"@imageSend_range": {
"placeholders": {
"min": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"imageSend_unknownValue": "—",
"imageSend_radioUnknownTitle": "Radio settings unknown",
"imageSend_radioUnknownBody": "Connect to a device so the time on air can be calculated.",
"imageSend_longSendTitle": "Long transmission",
"imageSend_longSendBody": "This will hold the channel for about {duration}.",
"@imageSend_longSendBody": {
"placeholders": {
"duration": {
"type": "String"
}
}
},
"imageSend_floodNote": "Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.",
"imageSend_parityTitle": "Add recovery packet",
"imageSend_paritySubtitle": "One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.",
"imageSend_send": "Send",
"imageSend_cancel": "Cancel",
"imageSend_encodeFailed": "This image could not be encoded.",
"imageSend_codecDownloading": "The image model is still downloading.",
"imageSend_codecUnavailable": "Image sending is not available on this device.",
"imageSend_codecDisabled": "Image messages are turned off in settings.",
"imageSend_deviceUnsupported": "This radio cannot send image packets. Connect a device running companion firmware 13 or newer.",
"imageSend_directMessagesUnsupported": "Images travel as group data, so they can only be sent to a channel — not in a direct message.",
"imageSend_tooLarge": "That image encoded to more packets than the mesh format allows.",
"imageSend_sentConfirmation": "Image sent as {count} {count, plural, =1{packet} other{packets}}.",
"@imageSend_sentConfirmation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"imageSend_sendFailed": "The image could not be sent: {error}",
"@imageSend_sendFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"imageSend_sendingProgress": "Sending image — packet {sent} of {total}",
"@imageSend_sendingProgress": {
"placeholders": {
"sent": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"receivedImage_senderPrefix": "Node {prefix}",
"@receivedImage_senderPrefix": {
"placeholders": {
"prefix": {
"type": "String"
}
}
},
"receivedImage_incoming": "{received} of {total} packets",
"@receivedImage_incoming": {
"placeholders": {
"received": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"receivedImage_queued": "Waiting to decode",
"receivedImage_tapToDecode": "Tap to decode",
"receivedImage_decoding": "Reconstructing… about 1 s",
"receivedImage_incomplete": "Image incomplete — {received} of {total} packets arrived",
"@receivedImage_incomplete": {
"placeholders": {
"received": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"receivedImage_corrupt": "Image could not be reconstructed",
"receivedImage_decoderMissing": "Image received — image decoding is off",
"receivedImage_evicted": "Image no longer stored",
"receivedImage_retry": "Try again",
"receivedImage_decodeAgain": "Decode again",
"receivedImage_openSettings": "Set up",
"receivedImage_tapToProcess": "Tap to process",
"receivedImage_awaiting": "{bytes} bytes · {packets} {packets, plural, =1{packet} other{packets}}",
"@receivedImage_awaiting": {
"placeholders": {
"bytes": {
"type": "int"
},
"packets": {
"type": "int"
}
}
},
"imageSend_secondsValue": "{seconds} s",
"@imageSend_secondsValue": {
"placeholders": {
"seconds": {
"type": "String"
}
}
},
"imageSend_minutesSecondsValue": "{minutes} m {seconds} s",
"@imageSend_minutesSecondsValue": {
"placeholders": {
"minutes": {
"type": "String"
},
"seconds": {
"type": "String"
}
}
}
} }
+366
View File
@@ -2734,6 +2734,18 @@ abstract class AppLocalizations {
/// **'Send GIF'** /// **'Send GIF'**
String get chat_sendGif; String get chat_sendGif;
/// No description provided for @chat_sendImage.
///
/// In en, this message translates to:
/// **'Send image'**
String get chat_sendImage;
/// Shown when picking or reading a photo to send fails
///
/// In en, this message translates to:
/// **'Couldn\'t open that image'**
String get chat_imagePickFailed;
/// No description provided for @chat_receivedGif. /// No description provided for @chat_receivedGif.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -7768,6 +7780,72 @@ abstract class AppLocalizations {
/// **'Translation'** /// **'Translation'**
String get translation_title; String get translation_title;
/// No description provided for @imageMessages_enableTitle.
///
/// In en, this message translates to:
/// **'Enable image messages'**
String get imageMessages_enableTitle;
/// No description provided for @imageMessages_enableSubtitle.
///
/// In en, this message translates to:
/// **'Send images over the mesh. Requires a one-time image model download.'**
String get imageMessages_enableSubtitle;
/// No description provided for @imageMessages_modelSectionTitle.
///
/// In en, this message translates to:
/// **'Image model'**
String get imageMessages_modelSectionTitle;
/// No description provided for @imageMessages_downloadModel.
///
/// In en, this message translates to:
/// **'Download'**
String get imageMessages_downloadModel;
/// No description provided for @imageMessages_cancelDownload.
///
/// In en, this message translates to:
/// **'Cancel'**
String get imageMessages_cancelDownload;
/// No description provided for @imageMessages_removeModel.
///
/// In en, this message translates to:
/// **'Remove model'**
String get imageMessages_removeModel;
/// No description provided for @imageMessages_modelReady.
///
/// In en, this message translates to:
/// **'Ready'**
String get imageMessages_modelReady;
/// No description provided for @imageMessages_modelNotPublished.
///
/// In en, this message translates to:
/// **'Not published yet — this build cannot download it.'**
String get imageMessages_modelNotPublished;
/// No description provided for @imageMessages_downloadFailed.
///
/// In en, this message translates to:
/// **'The image model could not be downloaded.'**
String get imageMessages_downloadFailed;
/// No description provided for @imageMessages_autoProcessTitle.
///
/// In en, this message translates to:
/// **'Process images automatically'**
String get imageMessages_autoProcessTitle;
/// No description provided for @imageMessages_autoProcessSubtitle.
///
/// In en, this message translates to:
/// **'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.'**
String get imageMessages_autoProcessSubtitle;
/// No description provided for @translation_enableTitle. /// No description provided for @translation_enableTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -8235,6 +8313,294 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unlock view from packet'** /// **'Unlock view from packet'**
String get pathMap_unfollowPacket; String get pathMap_unfollowPacket;
/// No description provided for @imageSend_title.
///
/// In en, this message translates to:
/// **'Send image'**
String get imageSend_title;
/// No description provided for @imageSend_cropNote.
///
/// In en, this message translates to:
/// **'Resized to 512 × 512 · aspect ratio not preserved'**
String get imageSend_cropNote;
/// No description provided for @imageSend_originalSize.
///
/// In en, this message translates to:
/// **'Original'**
String get imageSend_originalSize;
/// No description provided for @imageSend_onAirSize.
///
/// In en, this message translates to:
/// **'On air'**
String get imageSend_onAirSize;
/// No description provided for @imageSend_quality.
///
/// In en, this message translates to:
/// **'Quality'**
String get imageSend_quality;
/// No description provided for @imageSend_qualityStandard.
///
/// In en, this message translates to:
/// **'Standard'**
String get imageSend_qualityStandard;
/// No description provided for @imageSend_qualityHigh.
///
/// In en, this message translates to:
/// **'High'**
String get imageSend_qualityHigh;
/// No description provided for @imageSend_packetsLabel.
///
/// In en, this message translates to:
/// **'Packets'**
String get imageSend_packetsLabel;
/// No description provided for @imageSend_airtimeLabel.
///
/// In en, this message translates to:
/// **'Time on air'**
String get imageSend_airtimeLabel;
/// No description provided for @imageSend_sizeLabel.
///
/// In en, this message translates to:
/// **'Payload'**
String get imageSend_sizeLabel;
/// No description provided for @imageSend_packetsCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{packet} other{packets}}'**
String imageSend_packetsCount(int count);
/// No description provided for @imageSend_range.
///
/// In en, this message translates to:
/// **'{min}{max}'**
String imageSend_range(String min, String max);
/// No description provided for @imageSend_unknownValue.
///
/// In en, this message translates to:
/// **''**
String get imageSend_unknownValue;
/// No description provided for @imageSend_radioUnknownTitle.
///
/// In en, this message translates to:
/// **'Radio settings unknown'**
String get imageSend_radioUnknownTitle;
/// No description provided for @imageSend_radioUnknownBody.
///
/// In en, this message translates to:
/// **'Connect to a device so the time on air can be calculated.'**
String get imageSend_radioUnknownBody;
/// No description provided for @imageSend_longSendTitle.
///
/// In en, this message translates to:
/// **'Long transmission'**
String get imageSend_longSendTitle;
/// No description provided for @imageSend_longSendBody.
///
/// In en, this message translates to:
/// **'This will hold the channel for about {duration}.'**
String imageSend_longSendBody(String duration);
/// No description provided for @imageSend_floodNote.
///
/// In en, this message translates to:
/// **'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.'**
String get imageSend_floodNote;
/// No description provided for @imageSend_parityTitle.
///
/// In en, this message translates to:
/// **'Add recovery packet'**
String get imageSend_parityTitle;
/// No description provided for @imageSend_paritySubtitle.
///
/// In en, this message translates to:
/// **'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.'**
String get imageSend_paritySubtitle;
/// No description provided for @imageSend_send.
///
/// In en, this message translates to:
/// **'Send'**
String get imageSend_send;
/// No description provided for @imageSend_cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get imageSend_cancel;
/// No description provided for @imageSend_encodeFailed.
///
/// In en, this message translates to:
/// **'This image could not be encoded.'**
String get imageSend_encodeFailed;
/// No description provided for @imageSend_codecDownloading.
///
/// In en, this message translates to:
/// **'The image model is still downloading.'**
String get imageSend_codecDownloading;
/// No description provided for @imageSend_codecUnavailable.
///
/// In en, this message translates to:
/// **'Image sending is not available on this device.'**
String get imageSend_codecUnavailable;
/// No description provided for @imageSend_codecDisabled.
///
/// In en, this message translates to:
/// **'Image messages are turned off in settings.'**
String get imageSend_codecDisabled;
/// No description provided for @imageSend_deviceUnsupported.
///
/// In en, this message translates to:
/// **'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.'**
String get imageSend_deviceUnsupported;
/// No description provided for @imageSend_directMessagesUnsupported.
///
/// In en, this message translates to:
/// **'Images travel as group data, so they can only be sent to a channel — not in a direct message.'**
String get imageSend_directMessagesUnsupported;
/// No description provided for @imageSend_tooLarge.
///
/// In en, this message translates to:
/// **'That image encoded to more packets than the mesh format allows.'**
String get imageSend_tooLarge;
/// No description provided for @imageSend_sentConfirmation.
///
/// In en, this message translates to:
/// **'Image sent as {count} {count, plural, =1{packet} other{packets}}.'**
String imageSend_sentConfirmation(int count);
/// No description provided for @imageSend_sendFailed.
///
/// In en, this message translates to:
/// **'The image could not be sent: {error}'**
String imageSend_sendFailed(String error);
/// No description provided for @imageSend_sendingProgress.
///
/// In en, this message translates to:
/// **'Sending image — packet {sent} of {total}'**
String imageSend_sendingProgress(int sent, int total);
/// No description provided for @receivedImage_senderPrefix.
///
/// In en, this message translates to:
/// **'Node {prefix}'**
String receivedImage_senderPrefix(String prefix);
/// No description provided for @receivedImage_incoming.
///
/// In en, this message translates to:
/// **'{received} of {total} packets'**
String receivedImage_incoming(int received, int total);
/// No description provided for @receivedImage_queued.
///
/// In en, this message translates to:
/// **'Waiting to decode'**
String get receivedImage_queued;
/// No description provided for @receivedImage_tapToDecode.
///
/// In en, this message translates to:
/// **'Tap to decode'**
String get receivedImage_tapToDecode;
/// No description provided for @receivedImage_decoding.
///
/// In en, this message translates to:
/// **'Reconstructing… about 1 s'**
String get receivedImage_decoding;
/// No description provided for @receivedImage_incomplete.
///
/// In en, this message translates to:
/// **'Image incomplete — {received} of {total} packets arrived'**
String receivedImage_incomplete(int received, int total);
/// No description provided for @receivedImage_corrupt.
///
/// In en, this message translates to:
/// **'Image could not be reconstructed'**
String get receivedImage_corrupt;
/// No description provided for @receivedImage_decoderMissing.
///
/// In en, this message translates to:
/// **'Image received — image decoding is off'**
String get receivedImage_decoderMissing;
/// No description provided for @receivedImage_evicted.
///
/// In en, this message translates to:
/// **'Image no longer stored'**
String get receivedImage_evicted;
/// No description provided for @receivedImage_retry.
///
/// In en, this message translates to:
/// **'Try again'**
String get receivedImage_retry;
/// No description provided for @receivedImage_decodeAgain.
///
/// In en, this message translates to:
/// **'Decode again'**
String get receivedImage_decodeAgain;
/// No description provided for @receivedImage_openSettings.
///
/// In en, this message translates to:
/// **'Set up'**
String get receivedImage_openSettings;
/// No description provided for @receivedImage_tapToProcess.
///
/// In en, this message translates to:
/// **'Tap to process'**
String get receivedImage_tapToProcess;
/// No description provided for @receivedImage_awaiting.
///
/// In en, this message translates to:
/// **'{bytes} bytes · {packets} {packets, plural, =1{packet} other{packets}}'**
String receivedImage_awaiting(int bytes, int packets);
/// No description provided for @imageSend_secondsValue.
///
/// In en, this message translates to:
/// **'{seconds} s'**
String imageSend_secondsValue(String seconds);
/// No description provided for @imageSend_minutesSecondsValue.
///
/// In en, this message translates to:
/// **'{minutes} m {seconds} s'**
String imageSend_minutesSecondsValue(String minutes, String seconds);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+240
View File
@@ -1489,6 +1489,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get chat_sendGif => 'Изпрати GIF'; String get chat_sendGif => 'Изпрати GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4517,6 +4523,43 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get translation_title => 'Превод'; String get translation_title => 'Превод';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Активирайте превода'; String get translation_enableTitle => 'Активирайте превода';
@@ -4800,4 +4843,201 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Спри проследяването на пакета'; String get pathMap_unfollowPacket => 'Спри проследяването на пакета';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1490,6 +1490,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get chat_sendGif => 'GIF senden'; String get chat_sendGif => 'GIF senden';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4526,6 +4532,43 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get translation_title => 'Übersetzung'; String get translation_title => 'Übersetzung';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Aktivieren Sie die Übersetzung'; String get translation_enableTitle => 'Aktivieren Sie die Übersetzung';
@@ -4813,4 +4856,201 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Fixierung aufheben'; String get pathMap_unfollowPacket => 'Fixierung aufheben';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1459,6 +1459,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get chat_sendGif => 'Send GIF'; String get chat_sendGif => 'Send GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4446,6 +4452,43 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get translation_title => 'Translation'; String get translation_title => 'Translation';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Enable translation'; String get translation_enableTitle => 'Enable translation';
@@ -4727,4 +4770,201 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Unlock view from packet'; String get pathMap_unfollowPacket => 'Unlock view from packet';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1486,6 +1486,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get chat_sendGif => 'Enviar GIF'; String get chat_sendGif => 'Enviar GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4521,6 +4527,43 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get translation_title => 'Traducción'; String get translation_title => 'Traducción';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Habilitar la traducción'; String get translation_enableTitle => 'Habilitar la traducción';
@@ -4806,4 +4849,201 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Dejar de seguir el paquete'; String get pathMap_unfollowPacket => 'Dejar de seguir el paquete';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1488,6 +1488,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get chat_sendGif => 'Envoyer un GIF'; String get chat_sendGif => 'Envoyer un GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4542,6 +4548,43 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get translation_title => 'Traduction'; String get translation_title => 'Traduction';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Activer la traduction'; String get translation_enableTitle => 'Activer la traduction';
@@ -4827,4 +4870,201 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Déverrouiller la vue du paquet'; String get pathMap_unfollowPacket => 'Déverrouiller la vue du paquet';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1481,6 +1481,12 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get chat_sendGif => 'GIF küldése'; String get chat_sendGif => 'GIF küldése';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4515,6 +4521,43 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get translation_title => 'Fordítás'; String get translation_title => 'Fordítás';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Fordítás engedélyezése'; String get translation_enableTitle => 'Fordítás engedélyezése';
@@ -4798,4 +4841,201 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'A nézet feloldása a csomagból'; String get pathMap_unfollowPacket => 'A nézet feloldása a csomagból';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1487,6 +1487,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get chat_sendGif => 'Invia GIF'; String get chat_sendGif => 'Invia GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4525,6 +4531,43 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get translation_title => 'Traduzione'; String get translation_title => 'Traduzione';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Abilitare la traduzione'; String get translation_enableTitle => 'Abilitare la traduzione';
@@ -4811,4 +4854,201 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Sblocca la vista dal pacchetto'; String get pathMap_unfollowPacket => 'Sblocca la vista dal pacchetto';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1416,6 +1416,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get chat_sendGif => 'GIF を送信'; String get chat_sendGif => 'GIF を送信';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4294,6 +4300,43 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get translation_title => '翻訳'; String get translation_title => '翻訳';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => '翻訳機能を有効にする'; String get translation_enableTitle => '翻訳機能を有効にする';
@@ -4569,4 +4612,201 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'パケットの追跡を解除'; String get pathMap_unfollowPacket => 'パケットの追跡を解除';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1419,6 +1419,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get chat_sendGif => 'GIF 보내기'; String get chat_sendGif => 'GIF 보내기';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4302,6 +4308,43 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get translation_title => '번역'; String get translation_title => '번역';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => '번역 기능 활성화'; String get translation_enableTitle => '번역 기능 활성화';
@@ -4577,4 +4620,201 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => '패킷 고정 해제'; String get pathMap_unfollowPacket => '패킷 고정 해제';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1475,6 +1475,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get chat_sendGif => 'GIF verzenden'; String get chat_sendGif => 'GIF verzenden';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4503,6 +4509,43 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get translation_title => 'Vertaling'; String get translation_title => 'Vertaling';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Activeer vertaling'; String get translation_enableTitle => 'Activeer vertaling';
@@ -4786,4 +4829,201 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Weergave ontgrendelen van pakket'; String get pathMap_unfollowPacket => 'Weergave ontgrendelen van pakket';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1498,6 +1498,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get chat_sendGif => 'Wyślij GIF'; String get chat_sendGif => 'Wyślij GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4542,6 +4548,43 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get translation_title => 'Tłumaczenie'; String get translation_title => 'Tłumaczenie';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Włącz tłumaczenie'; String get translation_enableTitle => 'Włącz tłumaczenie';
@@ -4830,4 +4873,201 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Przestań śledzić pakiet'; String get pathMap_unfollowPacket => 'Przestań śledzić pakiet';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1485,6 +1485,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get chat_sendGif => 'Enviar GIF'; String get chat_sendGif => 'Enviar GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4520,6 +4526,43 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get translation_title => 'Tradução'; String get translation_title => 'Tradução';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Ativar a tradução'; String get translation_enableTitle => 'Ativar a tradução';
@@ -4802,4 +4845,201 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Liberar vista do pacote'; String get pathMap_unfollowPacket => 'Liberar vista do pacote';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1486,6 +1486,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get chat_sendGif => 'Отправить GIF'; String get chat_sendGif => 'Отправить GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4536,6 +4542,43 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get translation_title => 'Перевод'; String get translation_title => 'Перевод';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Включить перевод'; String get translation_enableTitle => 'Включить перевод';
@@ -4822,4 +4865,201 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Не следить за пакетом'; String get pathMap_unfollowPacket => 'Не следить за пакетом';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1475,6 +1475,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get chat_sendGif => 'Odoslať GIF'; String get chat_sendGif => 'Odoslať GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4501,6 +4507,43 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get translation_title => 'Preklad'; String get translation_title => 'Preklad';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Aktivovať preklad'; String get translation_enableTitle => 'Aktivovať preklad';
@@ -4786,4 +4829,201 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Odomknúť pohľad od paketu'; String get pathMap_unfollowPacket => 'Odomknúť pohľad od paketu';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1474,6 +1474,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get chat_sendGif => 'Pošlji GIF'; String get chat_sendGif => 'Pošlji GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4500,6 +4506,43 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get translation_title => 'Prevod'; String get translation_title => 'Prevod';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Omogočite prevod'; String get translation_enableTitle => 'Omogočite prevod';
@@ -4787,4 +4830,201 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Odkleni pogled od paketa'; String get pathMap_unfollowPacket => 'Odkleni pogled od paketa';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1467,6 +1467,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get chat_sendGif => 'Skicka GIF'; String get chat_sendGif => 'Skicka GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4473,6 +4479,43 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get translation_title => 'Översättning'; String get translation_title => 'Översättning';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Aktivera översättning'; String get translation_enableTitle => 'Aktivera översättning';
@@ -4757,4 +4800,201 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Lås upp vy från paket'; String get pathMap_unfollowPacket => 'Lås upp vy från paket';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1480,6 +1480,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get chat_sendGif => 'Надіслати GIF'; String get chat_sendGif => 'Надіслати GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4533,6 +4539,43 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get translation_title => 'Переклад'; String get translation_title => 'Переклад';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => 'Увімкнути переклад'; String get translation_enableTitle => 'Увімкнути переклад';
@@ -4822,4 +4865,201 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => 'Відв\'язати вигляд від пакету'; String get pathMap_unfollowPacket => 'Відв\'язати вигляд від пакету';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+240
View File
@@ -1402,6 +1402,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get chat_sendGif => '发送 GIF'; String get chat_sendGif => '发送 GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override @override
String get chat_receivedGif => 'Received a GIF'; String get chat_receivedGif => 'Received a GIF';
@@ -4190,6 +4196,43 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get translation_title => '翻译'; String get translation_title => '翻译';
@override
String get imageMessages_enableTitle => 'Enable image messages';
@override
String get imageMessages_enableSubtitle =>
'Send images over the mesh. Requires a one-time image model download.';
@override
String get imageMessages_modelSectionTitle => 'Image model';
@override
String get imageMessages_downloadModel => 'Download';
@override
String get imageMessages_cancelDownload => 'Cancel';
@override
String get imageMessages_removeModel => 'Remove model';
@override
String get imageMessages_modelReady => 'Ready';
@override
String get imageMessages_modelNotPublished =>
'Not published yet — this build cannot download it.';
@override
String get imageMessages_downloadFailed =>
'The image model could not be downloaded.';
@override
String get imageMessages_autoProcessTitle => 'Process images automatically';
@override
String get imageMessages_autoProcessSubtitle =>
'Reconstruct every image as soon as it arrives. Uses about 2 GB of memory for a second each time; leave off to reconstruct with a tap instead.';
@override @override
String get translation_enableTitle => '启用翻译功能'; String get translation_enableTitle => '启用翻译功能';
@@ -4464,4 +4507,201 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get pathMap_unfollowPacket => '解锁视图跟随'; String get pathMap_unfollowPacket => '解锁视图跟随';
@override
String get imageSend_title => 'Send image';
@override
String get imageSend_cropNote =>
'Resized to 512 × 512 · aspect ratio not preserved';
@override
String get imageSend_originalSize => 'Original';
@override
String get imageSend_onAirSize => 'On air';
@override
String get imageSend_quality => 'Quality';
@override
String get imageSend_qualityStandard => 'Standard';
@override
String get imageSend_qualityHigh => 'High';
@override
String get imageSend_packetsLabel => 'Packets';
@override
String get imageSend_airtimeLabel => 'Time on air';
@override
String get imageSend_sizeLabel => 'Payload';
@override
String imageSend_packetsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$count $_temp0';
}
@override
String imageSend_range(String min, String max) {
return '$min$max';
}
@override
String get imageSend_unknownValue => '';
@override
String get imageSend_radioUnknownTitle => 'Radio settings unknown';
@override
String get imageSend_radioUnknownBody =>
'Connect to a device so the time on air can be calculated.';
@override
String get imageSend_longSendTitle => 'Long transmission';
@override
String imageSend_longSendBody(String duration) {
return 'This will hold the channel for about $duration.';
}
@override
String get imageSend_floodNote =>
'Flood routing: every repeater in range retransmits each packet, so the channel stays busy longer than this.';
@override
String get imageSend_parityTitle => 'Add recovery packet';
@override
String get imageSend_paritySubtitle =>
'One extra packet. Group messages are not acknowledged, so this lets the receiver rebuild the image if a single packet is lost.';
@override
String get imageSend_send => 'Send';
@override
String get imageSend_cancel => 'Cancel';
@override
String get imageSend_encodeFailed => 'This image could not be encoded.';
@override
String get imageSend_codecDownloading =>
'The image model is still downloading.';
@override
String get imageSend_codecUnavailable =>
'Image sending is not available on this device.';
@override
String get imageSend_codecDisabled =>
'Image messages are turned off in settings.';
@override
String get imageSend_deviceUnsupported =>
'This radio cannot send image packets. Connect a device running companion firmware 13 or newer.';
@override
String get imageSend_directMessagesUnsupported =>
'Images travel as group data, so they can only be sent to a channel — not in a direct message.';
@override
String get imageSend_tooLarge =>
'That image encoded to more packets than the mesh format allows.';
@override
String imageSend_sentConfirmation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'packets',
one: 'packet',
);
return 'Image sent as $count $_temp0.';
}
@override
String imageSend_sendFailed(String error) {
return 'The image could not be sent: $error';
}
@override
String imageSend_sendingProgress(int sent, int total) {
return 'Sending image — packet $sent of $total';
}
@override
String receivedImage_senderPrefix(String prefix) {
return 'Node $prefix';
}
@override
String receivedImage_incoming(int received, int total) {
return '$received of $total packets';
}
@override
String get receivedImage_queued => 'Waiting to decode';
@override
String get receivedImage_tapToDecode => 'Tap to decode';
@override
String get receivedImage_decoding => 'Reconstructing… about 1 s';
@override
String receivedImage_incomplete(int received, int total) {
return 'Image incomplete — $received of $total packets arrived';
}
@override
String get receivedImage_corrupt => 'Image could not be reconstructed';
@override
String get receivedImage_decoderMissing =>
'Image received — image decoding is off';
@override
String get receivedImage_evicted => 'Image no longer stored';
@override
String get receivedImage_retry => 'Try again';
@override
String get receivedImage_decodeAgain => 'Decode again';
@override
String get receivedImage_openSettings => 'Set up';
@override
String get receivedImage_tapToProcess => 'Tap to process';
@override
String receivedImage_awaiting(int bytes, int packets) {
String _temp0 = intl.Intl.pluralLogic(
packets,
locale: localeName,
other: 'packets',
one: 'packet',
);
return '$bytes bytes · $packets $_temp0';
}
@override
String imageSend_secondsValue(String seconds) {
return '$seconds s';
}
@override
String imageSend_minutesSecondsValue(String minutes, String seconds) {
return '$minutes m $seconds s';
}
} }
+234 -12
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -9,7 +11,13 @@ import 'screens/chrome_required_screen.dart';
import 'utils/platform_info.dart'; import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart'; import 'connector/meshcore_connector.dart';
import 'models/image_codec_support.dart';
import 'screens/scanner_screen.dart'; import 'screens/scanner_screen.dart';
import 'services/image_chunk_transport.dart';
import 'services/image_codec_service.dart';
import 'services/image_codec_settings_store.dart';
import 'services/received_image_blob_store_factory.dart';
import 'services/received_image_store.dart';
import 'services/storage_service.dart'; import 'services/storage_service.dart';
import 'services/message_retry_service.dart'; import 'services/message_retry_service.dart';
import 'services/path_history_service.dart'; import 'services/path_history_service.dart';
@@ -26,6 +34,7 @@ import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'theme/mesh_theme.dart'; import 'theme/mesh_theme.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
import 'widgets/image_send_codec_binding.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -59,9 +68,52 @@ void main() async {
final uiViewStateService = UiViewStateService(); final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage); final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings // Load settings before anything reads them. The image stack below takes its
// model registry and its "process automatically" default straight off
// `appSettingsService.settings`, so this cannot stay where it used to be
// (after the constructions) without those two starting out wrong.
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
// ---- image messages (AEIC over GRP_DATA) --------------------------------
// The codec owns the ONNX decoder; the store owns received-image state and
// the decode queue; the reassembler/transport pair is the receive path the
// connector feeds raw frames into.
final imageCodecService = ImageCodecService(
appSettingsService,
settingsStore: AppSettingsImageCodecStore(appSettingsService),
);
final receivedImageStore = ReceivedImageStore(
// Without a file-backed store this silently falls back to memory and every
// received image sidecar included is gone at the next launch.
blobs: createReceivedImageBlobStore(),
decoder: ImageCodecServiceDecoder(imageCodecService),
processAutomatically: appSettingsService.settings.imageProcessAutomatically,
);
// A decode peaks around 2.16 GiB, so the setting is the user's consent to
// spend it. Mirrored on every settings change; the store applies it to
// future arrivals only, so turning it on does not decode a backlog.
appSettingsService.addListener(() {
receivedImageStore.processAutomatically =
appSettingsService.settings.imageProcessAutomatically;
});
// Availability/isBusy changes are the only thing that un-parks a decode
// queue that stopped because the codec was unusable or busy.
imageCodecService.addListener(receivedImageStore.notifyDecoderChanged);
final imageReassembler = ImageStreamReassembler(store: receivedImageStore);
final imageTransport = ImageChunkTransport(
reassembler: imageReassembler,
// The UI sends whole chunk sets through connector.sendImageChunks so it
// gets real inter-chunk pacing and per-chunk progress; this closure only
// keeps ImageChunkTransport.sendImage() usable on its own.
send: (blob, channelIndex) => connector.sendImageChunks(
<Uint8List>[blob],
channelIndex: channelIndex,
interChunkDelay: Duration.zero,
),
// Overwritten by onImageSenderPrefix as soon as SELF_INFO lands.
senderPrefix: 0,
);
// Initialize app logger // Initialize app logger
appLogger.initialize( appLogger.initialize(
appDebugLogService, appDebugLogService,
@@ -79,6 +131,8 @@ void main() async {
await chatTextScaleService.initialize(); await chatTextScaleService.initialize();
await translationService.refreshDownloadedModels(); await translationService.refreshDownloadedModels();
await imageCodecService.refreshDownloadedModels();
await receivedImageStore.load();
await uiViewStateService.initialize(); await uiViewStateService.initialize();
await timeoutPredictionService.initialize(); await timeoutPredictionService.initialize();
@@ -92,6 +146,12 @@ void main() async {
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
backgroundService: backgroundService, backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService, timeoutPredictionService: timeoutPredictionService,
imageCodecService: imageCodecService,
imageTransport: imageTransport,
// ImageStreamReassembler.addChunk already forwards every outcome to the
// store together with the channel index (which ImageChunkOutcome itself
// does not carry), so onImageChunk would be a second, channel-blind path.
onImageSenderPrefix: (prefix) => imageReassembler.selfPrefix = prefix,
); );
await connector.loadContactCache(); await connector.loadContactCache();
@@ -116,10 +176,107 @@ void main() async {
translationService: translationService, translationService: translationService,
uiViewStateService: uiViewStateService, uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService, timeoutPredictionService: timeoutPredictionService,
imageCodecService: imageCodecService,
receivedImageStore: receivedImageStore,
imageReassembler: imageReassembler,
), ),
); );
} }
/// [ReceivedImageDecoder] over [ImageCodecService].
///
/// Pure delegation. It exists so `received_image_store.dart` never imports
/// `image_codec_service.dart` (and so the store stays testable without an
/// 872 MB ONNX graph).
class ImageCodecServiceDecoder implements ReceivedImageDecoder {
final ImageCodecService service;
const ImageCodecServiceDecoder(this.service);
@override
ImageCodecAvailability get availability => service.availability;
@override
bool get isBusy => service.isBusy;
@override
Future<ImageCodecResult?> decodeBitstream({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
}) => service.decodeBitstream(
bitstream: bitstream,
ratePoint: ratePoint,
resolution: resolution,
);
@override
void cancelCodecJob() => service.cancelCodecJob();
}
/// [ImageCodecSettingsStore] backed by [AppSettingsService].
///
/// Replaces `PrefsImageCodecSettingsStore` now that the five `imageCodec*`
/// fields live on [AppSettings]; the JSON keys were already the same, so a
/// user upgrading keeps their model registry only if it was written through
/// this adapter the old standalone `image_codec_settings` blob is not
/// migrated, because a placeholder-URL build cannot have produced one.
class AppSettingsImageCodecStore implements ImageCodecSettingsStore {
final AppSettingsService _service;
const AppSettingsImageCodecStore(this._service);
@override
ImageCodecPreferences get preferences => _service.settings.imageCodec;
@override
Future<void> load() async {
// AppSettingsService.loadSettings() already ran in main().
}
@override
Future<void> save(ImageCodecPreferences preferences) =>
_service.setImageCodecPreferences(preferences);
}
/// An [ImageReassembler] that (a) learns the local sender prefix after
/// SELF_INFO and (b) forwards every outcome to [ReceivedImageStore] together
/// with the channel index.
///
/// Both exist because of upstream shapes this file cannot change:
/// * `ImageReassembler.selfPrefix` is `final`, but the local public key does
/// not exist until `RESP_CODE_SELF_INFO`, so a reassembler built here would
/// start with a null prefix and never be able to drop our own flood echo.
/// Overriding the getter is the smallest fix that does not touch the
/// transport; see the report's "needs from others".
/// * `ImageChunkOutcome` carries no channel index, so the connector's
/// `onImageChunk` callback cannot call `handleOutcome`, which requires one.
/// Intercepting `addChunk` the only place the index is in scope can.
class ImageStreamReassembler extends ImageReassembler {
final ReceivedImageStore store;
int? _selfPrefix;
ImageStreamReassembler({required this.store})
: super(onFailed: ((failure) => unawaited(store.handleFailure(failure))));
@override
int? get selfPrefix => _selfPrefix;
set selfPrefix(int? value) => _selfPrefix = value;
@override
ImageChunkOutcome addChunk(
Uint8List blob, {
int channelIndex = 0,
DateTime? now,
}) {
final outcome = super.addChunk(blob, channelIndex: channelIndex, now: now);
unawaited(store.handleOutcome(outcome, channelIndex: channelIndex));
return outcome;
}
}
void _registerThirdPartyLicenses() { void _registerThirdPartyLicenses() {
LicenseRegistry.addLicense(() async* { LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks( yield const LicenseEntryWithLineBreaks(
@@ -141,7 +298,7 @@ https://creativecommons.org/licenses/by/4.0/
}); });
} }
class MeshCoreApp extends StatelessWidget { class MeshCoreApp extends StatefulWidget {
final MeshCoreConnector connector; final MeshCoreConnector connector;
final MessageRetryService retryService; final MessageRetryService retryService;
final PathHistoryService pathHistoryService; final PathHistoryService pathHistoryService;
@@ -154,6 +311,9 @@ class MeshCoreApp extends StatelessWidget {
final TranslationService translationService; final TranslationService translationService;
final UiViewStateService uiViewStateService; final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService; final TimeoutPredictionService timeoutPredictionService;
final ImageCodecService imageCodecService;
final ReceivedImageStore receivedImageStore;
final ImageStreamReassembler imageReassembler;
const MeshCoreApp({ const MeshCoreApp({
super.key, super.key,
@@ -169,24 +329,86 @@ class MeshCoreApp extends StatelessWidget {
required this.translationService, required this.translationService,
required this.uiViewStateService, required this.uiViewStateService,
required this.timeoutPredictionService, required this.timeoutPredictionService,
required this.imageCodecService,
required this.receivedImageStore,
required this.imageReassembler,
}); });
@override
State<MeshCoreApp> createState() => _MeshCoreAppState();
}
/// How often abandoned image streams are swept.
///
/// `ImageReassembler.evictExpired` otherwise only runs from `addChunk`, so a
/// sender that stops mid-image would leave its bubble stuck on "2 of 3
/// packets" until some unrelated image arrived.
const Duration _kImageSweepInterval = Duration(seconds: 5);
class _MeshCoreAppState extends State<MeshCoreApp>
with WidgetsBindingObserver {
Timer? _imageSweepTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_imageSweepTimer = Timer.periodic(
_kImageSweepInterval,
(_) => widget.imageReassembler.evictExpired(),
);
}
@override
void dispose() {
_imageSweepTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
widget.receivedImageStore.setForeground(
state == AppLifecycleState.resumed,
);
// ~2.7 GiB resident in a backgrounded app is a low-memory-killer kill, so
// unlike the translation stack the graph is dropped on background rather
// than held until the radio disconnects.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden) {
unawaited(widget.imageCodecService.handleMemoryPressure());
unawaited(widget.receivedImageStore.handleMemoryPressure());
}
}
@override
void didHaveMemoryPressure() {
super.didHaveMemoryPressure();
unawaited(widget.imageCodecService.handleMemoryPressure());
unawaited(widget.receivedImageStore.handleMemoryPressure());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = widget.connector;
final storage = widget.storage;
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: connector), ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService), ChangeNotifierProvider.value(value: widget.retryService),
ChangeNotifierProvider.value(value: pathHistoryService), ChangeNotifierProvider.value(value: widget.pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: widget.appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: widget.bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: widget.appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService), ChangeNotifierProvider.value(value: widget.chatTextScaleService),
ChangeNotifierProvider.value(value: translationService), ChangeNotifierProvider.value(value: widget.translationService),
ChangeNotifierProvider.value(value: uiViewStateService), ChangeNotifierProvider.value(value: widget.uiViewStateService),
Provider.value(value: storage), Provider.value(value: storage),
ChangeNotifierProvider.value(value: mapTileCacheService), ChangeNotifierProvider.value(value: widget.mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService), ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
ChangeNotifierProvider.value(value: widget.imageCodecService),
ChangeNotifierProvider.value(value: widget.receivedImageStore),
], ],
child: Consumer<AppSettingsService>( child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) { builder: (context, settingsService, child) {
+89
View File
@@ -1,3 +1,4 @@
import 'image_codec_support.dart';
import 'translation_support.dart'; import 'translation_support.dart';
enum UnitSystem { metric, imperial } enum UnitSystem { metric, imperial }
@@ -118,6 +119,29 @@ class AppSettings {
final String tcpServerAddress; final String tcpServerAddress;
final int tcpServerPort; final int tcpServerPort;
final bool jumpToOldestUnread; final bool jumpToOldestUnread;
final bool imageMessagesEnabled;
/// Whether a received image is decoded as soon as it is reassembled.
///
/// Off by default and deliberately so: a decode peaks around 2.16 GiB
/// resident and takes about a second, so an unattended chat must not be able
/// to trigger one per arriving image. When false, `ReceivedImageStore` parks
/// the arrival as a "Tap to process" placeholder instead of queueing it.
final bool imageProcessAutomatically;
// ---- neural image codec (AEIC-SE) ---------------------------------------
// Structural twins of the translation block above; the JSON keys match
// ImageCodecPreferences.toJson so ImageCodecService reads them unchanged.
final bool imageCodecEnabled;
final String? imageCodecSelectedModelId;
final String? imageCodecModelSourceUrl;
/// [AeicRatePoint.wireValue] of the composer's default rate point.
/// 4 == ft32, the only rate point this build ships.
final int imageCodecRatePoint;
final List<ImageCodecModelRecord> imageCodecDownloadedModels;
final bool translationEnabled; final bool translationEnabled;
final bool autoTranslateIncomingMessages; final bool autoTranslateIncomingMessages;
final String? translationTargetLanguageCode; final String? translationTargetLanguageCode;
@@ -128,6 +152,16 @@ class AppSettings {
final List<Cyr2LatProfile> cyr2latProfiles; final List<Cyr2LatProfile> cyr2latProfiles;
final String selectedCyr2latProfileId; final String selectedCyr2latProfileId;
/// The five `imageCodec*` fields as the value object `ImageCodecService`
/// consumes. Assembled rather than stored so the settings blob stays flat.
ImageCodecPreferences get imageCodec => ImageCodecPreferences(
enabled: imageCodecEnabled,
selectedModelId: imageCodecSelectedModelId,
modelSourceUrl: imageCodecModelSourceUrl,
ratePoint: imageCodecRatePoint,
downloadedModels: imageCodecDownloadedModels,
);
String get effectiveMapTileApiKey { String get effectiveMapTileApiKey {
final apiKey = mapTileApiKey?.trim(); final apiKey = mapTileApiKey?.trim();
if (apiKey == null || apiKey.isEmpty) { if (apiKey == null || apiKey.isEmpty) {
@@ -187,6 +221,13 @@ class AppSettings {
this.tcpServerAddress = '', this.tcpServerAddress = '',
this.tcpServerPort = 0, this.tcpServerPort = 0,
this.jumpToOldestUnread = false, this.jumpToOldestUnread = false,
this.imageMessagesEnabled = false,
this.imageProcessAutomatically = false,
this.imageCodecEnabled = false,
this.imageCodecSelectedModelId,
this.imageCodecModelSourceUrl,
this.imageCodecRatePoint = 4,
List<ImageCodecModelRecord>? imageCodecDownloadedModels,
this.translationEnabled = false, this.translationEnabled = false,
this.autoTranslateIncomingMessages = true, this.autoTranslateIncomingMessages = true,
this.translationTargetLanguageCode, this.translationTargetLanguageCode,
@@ -199,6 +240,8 @@ class AppSettings {
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {}, mutedChannels = mutedChannels ?? {},
imageCodecDownloadedModels =
imageCodecDownloadedModels ?? const [],
translationDownloadedModels = translationDownloadedModels ?? const [], translationDownloadedModels = translationDownloadedModels ?? const [],
cyr2latProfiles = cyr2latProfiles =
cyr2latProfiles ?? cyr2latProfiles ??
@@ -254,6 +297,15 @@ class AppSettings {
'tcp_server_address': tcpServerAddress, 'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort, 'tcp_server_port': tcpServerPort,
'jump_to_oldest_unread': jumpToOldestUnread, 'jump_to_oldest_unread': jumpToOldestUnread,
'image_messages_enabled': imageMessagesEnabled,
'image_process_automatically': imageProcessAutomatically,
'image_codec_enabled': imageCodecEnabled,
'image_codec_selected_model_id': imageCodecSelectedModelId,
'image_codec_model_source_url': imageCodecModelSourceUrl,
'image_codec_rate_point': imageCodecRatePoint,
'image_codec_downloaded_models': imageCodecDownloadedModels
.map((model) => model.toJson())
.toList(),
'translation_enabled': translationEnabled, 'translation_enabled': translationEnabled,
'auto_translate_incoming_messages': autoTranslateIncomingMessages, 'auto_translate_incoming_messages': autoTranslateIncomingMessages,
'translation_target_language_code': translationTargetLanguageCode, 'translation_target_language_code': translationTargetLanguageCode,
@@ -343,6 +395,23 @@ class AppSettings {
tcpServerAddress: json['tcp_server_address'] as String? ?? '', tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0, tcpServerPort: json['tcp_server_port'] as int? ?? 0,
jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false, jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false,
imageMessagesEnabled: json['image_messages_enabled'] as bool? ?? false,
imageProcessAutomatically:
json['image_process_automatically'] as bool? ?? false,
imageCodecEnabled: json['image_codec_enabled'] as bool? ?? false,
imageCodecSelectedModelId:
json['image_codec_selected_model_id'] as String?,
imageCodecModelSourceUrl: json['image_codec_model_source_url'] as String?,
imageCodecRatePoint: json['image_codec_rate_point'] as int? ?? 4,
imageCodecDownloadedModels:
(json['image_codec_downloaded_models'] as List<dynamic>?)
?.map(
(entry) => ImageCodecModelRecord.fromJson(
Map<String, dynamic>.from(entry as Map),
),
)
.toList() ??
const [],
translationEnabled: json['translation_enabled'] as bool? ?? false, translationEnabled: json['translation_enabled'] as bool? ?? false,
autoTranslateIncomingMessages: autoTranslateIncomingMessages:
json['auto_translate_incoming_messages'] as bool? ?? true, json['auto_translate_incoming_messages'] as bool? ?? true,
@@ -439,6 +508,13 @@ class AppSettings {
String? tcpServerAddress, String? tcpServerAddress,
int? tcpServerPort, int? tcpServerPort,
bool? jumpToOldestUnread, bool? jumpToOldestUnread,
bool? imageMessagesEnabled,
bool? imageProcessAutomatically,
bool? imageCodecEnabled,
Object? imageCodecSelectedModelId = _unset,
Object? imageCodecModelSourceUrl = _unset,
int? imageCodecRatePoint,
List<ImageCodecModelRecord>? imageCodecDownloadedModels,
bool? translationEnabled, bool? translationEnabled,
bool? autoTranslateIncomingMessages, bool? autoTranslateIncomingMessages,
Object? translationTargetLanguageCode = _unset, Object? translationTargetLanguageCode = _unset,
@@ -506,6 +582,19 @@ class AppSettings {
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress, tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort, tcpServerPort: tcpServerPort ?? this.tcpServerPort,
jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread, jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread,
imageMessagesEnabled: imageMessagesEnabled ?? this.imageMessagesEnabled,
imageProcessAutomatically:
imageProcessAutomatically ?? this.imageProcessAutomatically,
imageCodecEnabled: imageCodecEnabled ?? this.imageCodecEnabled,
imageCodecSelectedModelId: imageCodecSelectedModelId == _unset
? this.imageCodecSelectedModelId
: imageCodecSelectedModelId as String?,
imageCodecModelSourceUrl: imageCodecModelSourceUrl == _unset
? this.imageCodecModelSourceUrl
: imageCodecModelSourceUrl as String?,
imageCodecRatePoint: imageCodecRatePoint ?? this.imageCodecRatePoint,
imageCodecDownloadedModels:
imageCodecDownloadedModels ?? this.imageCodecDownloadedModels,
translationEnabled: translationEnabled ?? this.translationEnabled, translationEnabled: translationEnabled ?? this.translationEnabled,
autoTranslateIncomingMessages: autoTranslateIncomingMessages:
autoTranslateIncomingMessages ?? this.autoTranslateIncomingMessages, autoTranslateIncomingMessages ?? this.autoTranslateIncomingMessages,
+836
View File
@@ -0,0 +1,836 @@
import 'dart:typed_data';
import '../widgets/image_send_codec_binding.dart';
/// Model registry + value types for the neural image codec (AEIC-SE).
///
/// Mirrors `lib/models/translation_support.dart` field-for-field so the
/// settings screen, the file store and the download machinery can be copied
/// from the translation stack with no structural changes.
enum ImageCodecStatus { none, pending, completed, failed, skipped }
extension ImageCodecStatusValue on ImageCodecStatus {
String get value {
switch (this) {
case ImageCodecStatus.pending:
return 'pending';
case ImageCodecStatus.completed:
return 'completed';
case ImageCodecStatus.failed:
return 'failed';
case ImageCodecStatus.skipped:
return 'skipped';
case ImageCodecStatus.none:
return 'none';
}
}
}
ImageCodecStatus parseImageCodecStatus(dynamic value) {
if (value is! String) {
return ImageCodecStatus.none;
}
for (final status in ImageCodecStatus.values) {
if (status.value == value) {
return status;
}
}
return ImageCodecStatus.none;
}
/// Shape of the decoder's only input tensor: `y_hat`, float32,
/// `[1, 256, 16, 16]`. Static the export has no dynamic axes and no batching.
const List<int> kImageCodecLatentShape = <int>[1, 256, 16, 16];
/// Element count implied by [kImageCodecLatentShape] (65,536).
const int kImageCodecLatentElements = 256 * 16 * 16;
/// Graph input/output names in the promoted export. Used as a preference when
/// binding tensors; the backend falls back to the graph's sole input/output if
/// a future re-export renames them.
const String kImageCodecDecoderInputName = 'y_hat';
const String kImageCodecDecoderOutputName = 'image';
/// Rate point = which `AEIC_SE_ft*.pkl` the encoder was fine-tuned from.
///
/// **ft32 IS THE ONLY SHIPPING RATE POINT** ([kShippingAeicRatePoint]). One
/// checkpoint ships, so [aeicRatePointForUi] is constant and the UI has no
/// quality selector to honour. The other members are retained *only* because
/// their ordinals are already persisted in `image_codec_rate_point` and cross
/// the isolate boundary, and because the measured byte counts below are real
/// data worth keeping. Do not reorder, do not add a second shipping entry
/// without a second model in [imageCodecPresetModels].
///
/// NOTE ON THE WIRE VALUE. This enum's ordinal is NOT the on-air rate nibble.
/// The on-air chunk-0 metadata byte is defined by `ImageStreamMetadata` in
/// `lib/services/image_chunk_transport.dart` and carries
/// [ImageCodecRatePoint.index], because the transport only ever exposes UI rate
/// points. [AeicRatePointValue]'s `wireValue` is the *model-selection ordinal*:
/// it is what gets persisted in settings and what crosses the isolate boundary
/// to pick a checkpoint. Convert with [aeicRatePointForUi] /
/// [uiRatePointForAeic]; never assume they are the same integer.
///
/// Measured real rANS bitstream sizes at 512x512 over 26 images
/// (Kodak 24 + 2 custom). `ft2`/`ft4` have not been measured on this corpus.
enum AeicRatePoint { ft2, ft4, ft8, ft16, ft32 }
/// The one rate point this build encodes and decodes at.
///
/// ft32: mean 155.8 B, min 110 B, max 209 B over the 26-image corpus, i.e. 1-2
/// data chunks plus one XOR parity chunk. ft16 was dropped: it needs 2-3 data
/// chunks and leaves only 79 B of headroom under the 3-chunk ceiling.
const AeicRatePoint kShippingAeicRatePoint = AeicRatePoint.ft32;
extension AeicRatePointValue on AeicRatePoint {
int get wireValue => index;
/// Mean measured bitstream size in bytes, or `null` where unmeasured.
int? get meanBytes {
switch (this) {
case AeicRatePoint.ft2:
case AeicRatePoint.ft4:
return null;
case AeicRatePoint.ft8:
return 507;
case AeicRatePoint.ft16:
return 288;
case AeicRatePoint.ft32:
return 156;
}
}
/// Largest bitstream observed on the measurement corpus, or `null`.
int? get maxBytes {
switch (this) {
case AeicRatePoint.ft2:
case AeicRatePoint.ft4:
return null;
case AeicRatePoint.ft8:
return 732;
case AeicRatePoint.ft16:
return 409;
case AeicRatePoint.ft32:
return 209;
}
}
/// `'ft32 (~156 B)'`. Uses the enum's own `name` for the prefix — do not add
/// a `name` getter here, it would collide with `dart:core`'s `EnumName`.
String get label {
final mean = meanBytes;
return mean == null ? name : '$name (~$mean B)';
}
}
AeicRatePoint parseAeicRatePoint(int wireValue) {
if (wireValue < 0 || wireValue >= AeicRatePoint.values.length) {
return kShippingAeicRatePoint;
}
return AeicRatePoint.values[wireValue];
}
/// Maps the UI-level rate selector onto the codec's rate points.
///
/// ft32-only, so this is deliberately constant and ignores [rate]. It is kept
/// as a function (rather than inlining [kShippingAeicRatePoint] at the call
/// sites) so that adding a second checkpoint is a one-function change, and so
/// this file never names an [ImageCodecRatePoint] member that enum is owned by
/// `image_send_codec_binding.dart` and its membership is the UI's to shrink.
AeicRatePoint aeicRatePointForUi(ImageCodecRatePoint rate) =>
kShippingAeicRatePoint;
/// Inverse of [aeicRatePointForUi]. Constant for the same reason: every rate
/// point this build can produce is the standard one.
ImageCodecRatePoint uiRatePointForAeic(AeicRatePoint rate) =>
ImageCodecRatePoint.standard;
/// Bundle layout revision written into [ImageCodecModelRecord.bundleVersion].
///
/// 0 means "installed before the entropy bundle existed", i.e. a decoder-only
/// install that can render a latent but cannot encode or decode a bitstream.
/// Bump this only when the *set of files* a model needs changes; it is what
/// `ImageCodecService.needsModelUpgrade` keys off.
///
/// 1 -> 2: the bundle gained the **decode-side** entropy graph
/// (`aeic_entropy_decode_fp32_op17.onnx`). A version-1 install has the
/// send-side graph only, so it can encode but cannot decode a bitstream; the
/// remedy is a re-download, which is exactly what `needsModelUpgrade` asks for.
const int kImageCodecBundleVersion = 2;
/// Same shape and field order as `TranslationModelRecord`, plus the two fields
/// a multi-asset install needs.
class ImageCodecModelRecord {
final String id;
final String name;
final String sourceUrl;
/// Path of the **decoder graph** the file ONNX Runtime is handed. The other
/// assets are its siblings in the same directory; see [assetFileNames].
final String localPath;
final DateTime downloadedAt;
final int fileSizeBytes;
/// Every file that was installed for this model, by exact on-disk name.
///
/// Recorded rather than re-derived so the service can locate the entropy
/// graph and the CDF tables without guessing filenames, and so `deleteModel`
/// can remove all ~1.0 GB instead of just the 3 MB graph.
final List<String> assetFileNames;
/// What each installed file IS, keyed by its exact on-disk name.
///
/// Recorded at install time because [assetFileNames] alone stopped being
/// enough at bundle version 2: the bundle now holds THREE `.onnx` files
/// the synthesis decoder, the send-side entropy graph and the decode-side
/// entropy graph and no property of a filename distinguishes the last two.
/// Handing the decode-side export to the encoder fails at the first run;
/// handing the send-side one to the decoder would feed rANS the wrong
/// probabilities, which is the silent-corruption failure mode.
///
/// Empty for records written before this field existed. Consumers fall back
/// to the registry spec, and only then to a filename heuristic.
final Map<String, ImageCodecAssetRole> assetRoles;
/// [kImageCodecBundleVersion] at install time; 0 for a pre-bundle install.
final int bundleVersion;
const ImageCodecModelRecord({
required this.id,
required this.name,
required this.sourceUrl,
required this.localPath,
required this.downloadedAt,
required this.fileSizeBytes,
this.assetFileNames = const [],
this.assetRoles = const {},
this.bundleVersion = 0,
});
/// The installed file recorded as [role], or null when none was.
String? fileNameForRole(ImageCodecAssetRole role) {
for (final entry in assetRoles.entries) {
if (entry.value == role && assetFileNames.contains(entry.key)) {
return entry.key;
}
}
return null;
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'source_url': sourceUrl,
'local_path': localPath,
'downloaded_at': downloadedAt.millisecondsSinceEpoch,
'file_size_bytes': fileSizeBytes,
'asset_file_names': assetFileNames,
// Role NAMES, not ordinals: appending a member to ImageCodecAssetRole
// must never re-label files already on disk.
'asset_roles': <String, String>{
for (final entry in assetRoles.entries) entry.key: entry.value.name,
},
'bundle_version': bundleVersion,
};
}
factory ImageCodecModelRecord.fromJson(Map<String, dynamic> json) {
final rawAssets = json['asset_file_names'];
final rawRoles = json['asset_roles'];
return ImageCodecModelRecord(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
sourceUrl: json['source_url'] as String? ?? '',
localPath: json['local_path'] as String? ?? '',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(
json['downloaded_at'] as int? ?? 0,
),
fileSizeBytes: json['file_size_bytes'] as int? ?? 0,
assetFileNames: rawAssets is List
? [
for (final entry in rawAssets)
if (entry is String && entry.isNotEmpty) entry,
]
: const [],
assetRoles: rawRoles is Map
? <String, ImageCodecAssetRole>{
for (final entry in rawRoles.entries)
if (entry.key is String && entry.value is String)
entry.key as String: ?parseImageCodecAssetRole(
entry.value as String,
),
}
: const {},
bundleVersion: json['bundle_version'] as int? ?? 0,
);
}
ImageCodecModelRecord copyWith({
String? id,
String? name,
String? sourceUrl,
String? localPath,
DateTime? downloadedAt,
int? fileSizeBytes,
List<String>? assetFileNames,
Map<String, ImageCodecAssetRole>? assetRoles,
int? bundleVersion,
}) {
return ImageCodecModelRecord(
id: id ?? this.id,
name: name ?? this.name,
sourceUrl: sourceUrl ?? this.sourceUrl,
localPath: localPath ?? this.localPath,
downloadedAt: downloadedAt ?? this.downloadedAt,
fileSizeBytes: fileSizeBytes ?? this.fileSizeBytes,
assetFileNames: assetFileNames ?? this.assetFileNames,
assetRoles: assetRoles ?? this.assetRoles,
bundleVersion: bundleVersion ?? this.bundleVersion,
);
}
}
String imageCodecModelFriendlyName(ImageCodecModelRecord model) {
for (final spec in imageCodecPresetModels) {
if (spec.id == model.id || spec.fileName == model.name) {
return spec.label;
}
}
final trimmed = model.name.trim();
if (trimmed.endsWith('.onnx')) {
return trimmed.substring(0, trimmed.length - 5);
}
return trimmed.isEmpty ? model.id : trimmed;
}
/// What a downloaded file *is*, so nothing has to infer it from position or
/// filename.
///
/// This exists because the bundle stopped being "graph, then its data sibling".
/// `assets.first` is a decoder graph today and would silently become the
/// entropy graph the moment anyone reordered the list; the ONNX session would
/// then load 67 MB of entropy model and fail on an input named `y_hat`.
enum ImageCodecAssetRole {
/// The synthesis graph handed to ORT: `y_hat -> image`.
decoderGraph,
/// `<decoderGraph>.data`. ORT resolves it by the literal filename recorded
/// inside the graph, so it MUST sit beside [decoderGraph] under that name.
decoderWeights,
/// The fp32 **send-side** entropy graph: one input `image [1,3,512,512]`,
/// outputs `z_q`, `yq0..3`, `sc0..3`. Kept fp32 deliberately see the
/// bit-exactness note in `image_codec_backend.dart`.
///
/// This is the ENCODE half only. It emits every stage at once, which is valid
/// because the encoder already knows `y`; a decoder cannot use it at all.
entropyGraph,
/// Reserved. The 67 MB entropy export is self-contained today and carries no
/// external-data sibling; the role exists so a future re-export that does is
/// a registry change rather than a schema change.
entropyWeights,
/// The quantised CDF tables the rANS coder indexes. A *wrong* table set
/// desynchronises rANS and yields a sharp, plausible, wrong image with no
/// error, which is why this asset must carry a SHA-256 before ship.
cdfTables,
/// The fp32 **decode-side** entropy graph, added in bundle version 2.
///
/// Decoding is inherently sequential the symbols of stage `i` must be
/// decoded before stage `i+1`'s context exists — so the receiver cannot use
/// the emit-everything-at-once [entropyGraph]. This export is the same
/// sub-networks behind an ONNX `If` on a `stage` selector, so the app can
/// call it five times per image:
///
/// inputs : `z_q [1,128,4,4]`, `base [1,256,16,16]`, `stage int32 [1]`
/// outputs: `base0`, `means`, `scales`, all `[1,256,16,16]`
///
/// `stage < 0` runs the hyper-synthesis branch and fills `base0`;
/// `stage 0..3` runs the context branch and fills `means`/`scales`, already
/// multiplied by that stage's mask. The branch not taken emits zeros.
///
/// Appended after [cdfTables] rather than beside [entropyGraph] so no
/// existing enum ordinal moves.
entropyDecodeGraph,
}
/// Parses a role written by [ImageCodecModelRecord.toJson], or null when the
/// string names no role this build knows.
///
/// Null rather than a default: a record from a NEWER build may name a role that
/// does not exist here, and silently filing it under some other role would be
/// worse than admitting the file's purpose is unknown.
ImageCodecAssetRole? parseImageCodecAssetRole(String value) {
for (final role in ImageCodecAssetRole.values) {
if (role.name == value) {
return role;
}
}
return null;
}
/// One downloadable file belonging to a model.
///
/// A model is not always one file. The promoted decoder is an ONNX graph plus an
/// external-weights sibling (`<name>.onnx.data`, referenced by relative filename
/// from inside the graph), and ONNX Runtime will not load the graph unless the
/// sibling sits next to it under exactly that name. Downloading only the 2.8 MB
/// graph produces an `ORT_INVALID_PROTOBUF`-class failure at session creation,
/// which is indistinguishable from a corrupt download hence [sha256].
class ImageCodecModelAsset {
/// Name the file must have on disk. Not derived from the URL: the external
/// data reference inside the graph is a literal filename.
final String fileName;
final String sourceUrl;
/// Expected size in bytes. Advisory the download re-reads it from the HEAD
/// response but it is what the UI shows before the first byte arrives.
final int sizeBytes;
/// Lowercase hex SHA-256 of the file, or `''` when no digest is known.
///
/// An empty value **skips verification**; see [hasChecksum]. That is the state
/// today, deliberately: nothing has been uploaded, so no digest of a
/// *published* file exists and a hard-failing guess would block the feature.
final String sha256;
/// What this file is for. See [ImageCodecAssetRole].
final ImageCodecAssetRole role;
const ImageCodecModelAsset({
required this.fileName,
required this.sourceUrl,
required this.sizeBytes,
required this.role,
this.sha256 = '',
});
bool get hasChecksum => sha256.length == 64;
}
/// A declared preset model: its identity plus every file it needs on disk.
///
/// Mirrors the role of `translationPresetModels` (translation_support.dart:117)
/// but is a *spec*, not an [ImageCodecModelRecord]. Records describe what is
/// actually on this device (path, mtime, real size); specs describe what could
/// be fetched. Conflating them is what made the translation stack unable to
/// express a two-file model.
class ImageCodecModelSpec {
final String id;
final String label;
/// Every file the model needs, tagged by [ImageCodecAssetRole]. Downloaded in
/// list order into one directory; the order is a download preference only
/// nothing resolves an asset by position.
final List<ImageCodecModelAsset> assets;
/// True while [assets] carry URLs that have never been fetched by anybody.
/// The UI must not offer a download for a spec with this set unless the user
/// has supplied their own URL. See the note on [imageCodecPresetModels].
final bool urlsArePlaceholders;
/// Which checkpoint these graphs and tables belong to.
///
/// Tables and graphs are per-checkpoint and are not interchangeable: decoding
/// an ft32 bitstream with ft16 tables desynchronises rANS. The bundle's rate
/// point is checked against the bitstream's chunk-0 metadata before a decode.
final AeicRatePoint ratePoint;
const ImageCodecModelSpec({
required this.id,
required this.label,
required this.assets,
this.ratePoint = kShippingAeicRatePoint,
this.urlsArePlaceholders = false,
});
/// The one asset with [role], or throws when the spec omits it.
ImageCodecModelAsset assetFor(ImageCodecAssetRole role) =>
assets.firstWhere((asset) => asset.role == role);
ImageCodecModelAsset? maybeAssetFor(ImageCodecAssetRole role) {
for (final asset in assets) {
if (asset.role == role) return asset;
}
return null;
}
/// The decoder graph the path handed to ONNX Runtime.
///
/// Defined by role, not by position. Kept under the old name so the settings
/// screen and [imageCodecModelFriendlyName] keep working.
ImageCodecModelAsset get graph =>
assetFor(ImageCodecAssetRole.decoderGraph);
String get fileName => graph.fileName;
int get totalSizeBytes =>
assets.fold<int>(0, (sum, asset) => sum + asset.sizeBytes);
/// True when this spec describes a bundle that can encode AND decode.
///
/// All five roles, not four: without [ImageCodecAssetRole.entropyDecodeGraph]
/// the install could send a picture and never open one.
bool get isComplete =>
maybeAssetFor(ImageCodecAssetRole.decoderGraph) != null &&
maybeAssetFor(ImageCodecAssetRole.decoderWeights) != null &&
maybeAssetFor(ImageCodecAssetRole.entropyGraph) != null &&
maybeAssetFor(ImageCodecAssetRole.entropyDecodeGraph) != null &&
maybeAssetFor(ImageCodecAssetRole.cdfTables) != null;
}
/// The set of on-disk paths a codec session needs, replacing the bare
/// `String modelPath` that used to cross the isolate boundary.
///
/// [entropyGraphPath], [entropyDecodeGraphPath] and [tablesPath] are nullable
/// because a build that was installed before the entropy bundle existed has
/// none of them, and that install must keep working for latent synthesis rather
/// than becoming unloadable.
class ImageCodecBundle {
/// `*.onnx`; its `*.onnx.data` sibling must be in the same directory under
/// the exact filename the graph records.
final String decoderGraphPath;
/// fp32 **send-side** entropy graph (`image -> z_q, yq*, sc*`), or null on a
/// pre-bundle install. Encoding needs this one and only this one.
final String? entropyGraphPath;
/// fp32 **decode-side** entropy graph (`z_q, base, stage -> base0, means,
/// scales`), or null on a bundle-version-1 (or pre-bundle) install.
///
/// Separate from [entropyGraphPath] because they are two different exports of
/// the same weights: the send side emits all four stages at once, which a
/// decoder cannot use, and a decoder needs the `If`-branched graph it can call
/// five times per image. See [ImageCodecAssetRole.entropyDecodeGraph].
final String? entropyDecodeGraphPath;
/// CDF tables binary, or null on a pre-bundle install.
final String? tablesPath;
/// Checkpoint the graphs and tables belong to.
final AeicRatePoint ratePoint;
const ImageCodecBundle({
required this.decoderGraphPath,
this.entropyGraphPath,
this.entropyDecodeGraphPath,
this.tablesPath,
this.ratePoint = kShippingAeicRatePoint,
});
/// True when this install can run the **send** half of the entropy path.
///
/// Deliberately does NOT require [entropyDecodeGraphPath]: a version-1 install
/// can still encode, and reporting it as unable to do anything would be wrong.
/// Use [supportsDecode] to gate a decode.
bool get isComplete => entropyGraphPath != null && tablesPath != null;
/// True when this install can also turn a bitstream back into a picture.
bool get supportsDecode => isComplete && entropyDecodeGraphPath != null;
@override
bool operator ==(Object other) =>
other is ImageCodecBundle &&
other.decoderGraphPath == decoderGraphPath &&
other.entropyGraphPath == entropyGraphPath &&
other.entropyDecodeGraphPath == entropyDecodeGraphPath &&
other.tablesPath == tablesPath &&
other.ratePoint == ratePoint;
@override
int get hashCode => Object.hash(
decoderGraphPath,
entropyGraphPath,
entropyDecodeGraphPath,
tablesPath,
ratePoint,
);
@override
String toString() =>
'ImageCodecBundle(decoder: $decoderGraphPath, entropy: $entropyGraphPath, '
'entropyDecode: $entropyDecodeGraphPath, tables: $tablesPath, '
'rate: ${ratePoint.name})';
}
/// Published bundle: https://huggingface.co/zjs81/aeic-se-onnx
///
/// URL shape matches `translation_support.dart`:
/// `https://huggingface.co/<repo>/resolve/main/<file>?download=true`.
///
/// Sizes and digests below are of the published bytes, verified against the
/// local exports under `/Users/Zach/Documents/mycode/aic`. The CDF digest is
/// not optional: a table set that disagrees with the checkpoint desynchronises
/// rANS and produces a sharp, plausible, WRONG image with no error anywhere.
///
/// EXACTLY ONE ENTRY, and it is ONE bundle rather than a send-only and a
/// receive-capable download: encode needs the entropy graph + tables, decode
/// needs those *and* the decoder, so splitting them only creates an install
/// that can do half the feature.
///
/// WHY `qdq_conv_pct` AND NOT `qdq_conv_pct_novae`. The `novae` variant leaves
/// the VAE decoder in fp32, and the VAE is the only part that runs at full
/// 512x512, so its activations dominate: 872 MB on disk and 2.99 GiB peak RSS
/// against 835 MB / 2.16 GiB here, for 0.17 dB against the ORIGINAL image.
/// 830 MB of phone RAM is the wrong price for that on the platform where RAM,
/// not quality, is the binding constraint.
final List<ImageCodecModelSpec> imageCodecPresetModels = [
const ImageCodecModelSpec(
id: 'aeic-se-ft32-bundle-v1',
label: 'AEIC-SE 512 image codec (ft32)',
ratePoint: AeicRatePoint.ft32,
urlsArePlaceholders: false,
assets: [
ImageCodecModelAsset(
role: ImageCodecAssetRole.decoderGraph,
fileName: 'aeic_decoder_qdq_conv_pct.onnx',
sourceUrl:
'https://huggingface.co/zjs81/aeic-se-onnx/resolve/main/aeic_decoder_qdq_conv_pct.onnx?download=true',
sizeBytes: 3066597,
sha256:
'fa1ca65c52ecb9e1ec43c05ef792ac8b95ecab21dcb6ab89a825b4dad6a5a571',
),
ImageCodecModelAsset(
role: ImageCodecAssetRole.decoderWeights,
fileName: 'aeic_decoder_qdq_conv_pct.onnx.data',
sourceUrl:
'https://huggingface.co/zjs81/aeic-se-onnx/resolve/main/aeic_decoder_qdq_conv_pct.onnx.data?download=true',
sizeBytes: 872896480,
sha256:
'f7714df0ec8cc495be1fb4bad3be0458c186c8a61d87b8487f2e8e6b84b8242a',
),
ImageCodecModelAsset(
role: ImageCodecAssetRole.entropyGraph,
fileName: 'aeic_entropy_side_fp32_op17.onnx',
sourceUrl:
'https://huggingface.co/zjs81/aeic-se-onnx/resolve/main/aeic_entropy_side_fp32_op17.onnx?download=true',
sizeBytes: 67262167,
sha256:
'b7b55b0f6a8a02ec2e8f6e85820c064c741c870c226d276c78df45e83ca1a9d6',
),
// Local digest, for the uploader to confirm the bytes match:
// efcbbc4829a0029f487f17b7b52373c6af339d7f74a6417463981d0778d6d444
ImageCodecModelAsset(
role: ImageCodecAssetRole.entropyDecodeGraph,
fileName: 'aeic_entropy_decode_fp32_op17.onnx',
sourceUrl:
'https://huggingface.co/zjs81/aeic-se-onnx/resolve/main/aeic_entropy_decode_fp32_op17.onnx?download=true',
sizeBytes: 60509540,
sha256:
'efcbbc4829a0029f487f17b7b52373c6af339d7f74a6417463981d0778d6d444',
),
ImageCodecModelAsset(
role: ImageCodecAssetRole.cdfTables,
fileName: 'aeic_cdf_ft32.bin',
sourceUrl:
'https://huggingface.co/zjs81/aeic-se-onnx/resolve/main/aeic_cdf_ft32.bin?download=true',
sizeBytes: 813648,
sha256:
'4089fde2af16c340642a5c857be42f6d0f21caf71dd5b4f32d62efcd41c77bd5',
),
],
),
];
/// Total download for the one shipping bundle: 1,004,548,432 B (958.0 MiB).
///
/// Named so the pre-flight space check and the tests share one number instead
/// of each re-summing the asset list. Breakdown: 835 MiB decoder (graph +
/// external weights), 64.1 MiB send-side entropy graph, 57.7 MiB decode-side
/// entropy graph, 0.8 MiB CDF tables.
const int kImageCodecBundleTotalBytes = 1004548432;
/// Result of an encode or a decode.
class ImageCodecResult {
/// Encode result: the compressed payload only, with no chunk headers.
final Uint8List? bitstream;
/// Decode result: PNG-encoded RGB image.
final Uint8List? pngBytes;
/// Decode result: packed 8-bit RGB, `resolution * resolution * 3` bytes.
///
/// This is what the inference backend actually produces; [pngBytes] is an
/// optional re-encoding of it for widgets that want an `Image.memory`.
final Uint8List? rgbBytes;
final AeicRatePoint ratePoint;
/// Square edge length. 512 today and a hard floor the SD-Turbo UNet in the
/// decoder needs a 64x64 latent and collapses below it.
final int resolution;
final int durationMs;
final ImageCodecStatus status;
const ImageCodecResult({
required this.ratePoint,
required this.resolution,
required this.durationMs,
required this.status,
this.bitstream,
this.pngBytes,
this.rgbBytes,
});
}
class ImageCodecDownloadCancelled implements Exception {
const ImageCodecDownloadCancelled();
@override
String toString() => 'Download canceled.';
}
/// Raised by the codec session while the native inference seam is unimplemented.
class ImageCodecUnimplemented implements Exception {
final String detail;
const ImageCodecUnimplemented(this.detail);
@override
String toString() => 'Image codec backend not implemented: $detail';
}
/// Raised when a bitstream-level [encode]/[decode] is attempted but the entropy
/// path is not present in this build.
///
/// WHY THIS EXISTS AND WHY IT IS NOT A BUG TO FIX HERE. The shipping ONNX
/// artifact is the **synthesis half only**: `y_hat [1,256,16,16] -> image
/// [1,3,512,512]`. Turning bytes into `y_hat` (and back) needs two more things
/// that do not exist in Dart or in the shipped artifact:
///
/// 1. the entropy-side graph (`h_s`, `g_c` and the four adapters, kept in
/// fp32 `aic/onnx/aeic_entropy_side_fp32_op*.onnx`, 67 MB, not exported
/// into the shipped model), and
/// 2. the rANS range coder itself, which today exists only as the AEIC C++
/// extension (`aic/aeic/src/codec/MLCodec_rans*.so`).
///
/// Until both land, [ImageCodecBackend.decodeLatentToRgb] is the real, working
/// entry point and the bitstream methods throw this. Faking them is not an
/// option: an rANS desync produces a sharp, plausible, *wrong* image with no
/// error raised, so a stub that returns noise-shaped bytes would be
/// indistinguishable from the genuine failure mode this codec already has.
class ImageCodecEntropyPathMissing extends ImageCodecUnimplemented {
const ImageCodecEntropyPathMissing([
super.detail =
'the rANS entropy path (entropy-side graph + range coder) is not part '
'of this build; only latent->image synthesis is available',
]);
}
/// Raised when a bitstream operation is attempted against an install that
/// predates the entropy bundle.
///
/// Distinct from [ImageCodecEntropyPathMissing] on purpose. That one means the
/// *build* cannot do this and no user action helps; this one means the *files
/// on this device* are a decoder-only install and the remedy is a download.
/// Reporting the second as the first is what would strand every user who
/// installed the decoder-only build.
class ImageCodecBundleIncomplete extends ImageCodecUnimplemented {
const ImageCodecBundleIncomplete([
super.detail =
'the installed model predates the entropy bundle; re-download the '
'image codec model',
]);
}
/// Raised when a downloaded asset's SHA-256 does not match the expected digest.
class ImageCodecIntegrityFailure implements Exception {
final String fileName;
final String expected;
final String actual;
const ImageCodecIntegrityFailure({
required this.fileName,
required this.expected,
required this.actual,
});
@override
String toString() =>
'Checksum mismatch for $fileName: expected $expected, got $actual.';
}
/// Persisted image-codec preferences.
///
/// These five fields are destined for `AppSettings` (see the integration notes
/// for task B4, item 1); they live in their own value type until then so this
/// workstream adds no edits to `app_settings.dart`. The JSON keys already match
/// the `image_codec_*` names the AppSettings migration will use, so moving them
/// is a copy, not a rewrite.
class ImageCodecPreferences {
final bool enabled;
final String? selectedModelId;
final String? modelSourceUrl;
/// [AeicRatePoint.wireValue] of the composer's default rate point.
final int ratePoint;
final List<ImageCodecModelRecord> downloadedModels;
const ImageCodecPreferences({
this.enabled = false,
this.selectedModelId,
this.modelSourceUrl,
this.ratePoint = 4, // AeicRatePoint.ft32
this.downloadedModels = const [],
});
AeicRatePoint get aeicRatePoint => parseAeicRatePoint(ratePoint);
Map<String, dynamic> toJson() {
return {
'image_codec_enabled': enabled,
'image_codec_selected_model_id': selectedModelId,
'image_codec_model_source_url': modelSourceUrl,
'image_codec_rate_point': ratePoint,
'image_codec_downloaded_models': [
for (final model in downloadedModels) model.toJson(),
],
};
}
factory ImageCodecPreferences.fromJson(Map<String, dynamic> json) {
final rawModels = json['image_codec_downloaded_models'];
return ImageCodecPreferences(
enabled: json['image_codec_enabled'] as bool? ?? false,
selectedModelId: json['image_codec_selected_model_id'] as String?,
modelSourceUrl: json['image_codec_model_source_url'] as String?,
ratePoint: json['image_codec_rate_point'] as int? ?? 4,
downloadedModels: rawModels is List
? [
for (final entry in rawModels)
if (entry is Map<String, dynamic>)
ImageCodecModelRecord.fromJson(entry),
]
: const [],
);
}
static const Object _unset = Object();
ImageCodecPreferences copyWith({
bool? enabled,
Object? selectedModelId = _unset,
Object? modelSourceUrl = _unset,
int? ratePoint,
List<ImageCodecModelRecord>? downloadedModels,
}) {
return ImageCodecPreferences(
enabled: enabled ?? this.enabled,
selectedModelId: identical(selectedModelId, _unset)
? this.selectedModelId
: selectedModelId as String?,
modelSourceUrl: identical(modelSourceUrl, _unset)
? this.modelSourceUrl
: modelSourceUrl as String?,
ratePoint: ratePoint ?? this.ratePoint,
downloadedModels: downloadedModels ?? this.downloadedModels,
);
}
}
+250 -1
View File
@@ -6,8 +6,10 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/app_settings.dart'; import '../models/app_settings.dart';
import '../models/image_codec_support.dart';
import '../models/translation_support.dart'; import '../models/translation_support.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/image_codec_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
@@ -19,7 +21,16 @@ import '../helpers/snack_bar_builder.dart';
import 'map_cache_screen.dart'; import 'map_cache_screen.dart';
class AppSettingsScreen extends StatelessWidget { class AppSettingsScreen extends StatelessWidget {
const AppSettingsScreen({super.key}); /// Scrolls the image-message settings into view once, on first build.
///
/// This is how the received-image placeholder's "no model installed" tap
/// lands somewhere useful: the image block sits below appearance,
/// notifications, messaging, battery, map and translation, so pushing the
/// plain settings screen would drop the user at the top with no hint that
/// the thing they tapped for is several screens down.
final bool focusImageMessages;
const AppSettingsScreen({super.key, this.focusImageMessages = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -598,10 +609,206 @@ class AppSettingsScreen extends StatelessWidget {
settingsService.setEnableMessageTracing(value); settingsService.setEnableMessageTracing(value);
}, },
), ),
const Divider(height: 1, indent: 16),
_ScrollIntoViewOnce(
enabled: focusImageMessages,
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
secondary: const Icon(Icons.image_outlined, size: 20),
title: Text(context.l10n.imageMessages_enableTitle),
subtitle: Text(context.l10n.imageMessages_enableSubtitle),
value: settingsService.settings.imageMessagesEnabled,
onChanged: (value) async {
// ImageCodecService.availability requires BOTH the app-level
// toggle and its own `enabled` preference; one switch drives both
// so the user is never left with a silently half-on feature.
final codec = _imageCodecService(context);
await settingsService.setImageMessagesEnabled(value);
await codec?.setEnabled(value);
},
),
),
if (settingsService.settings.imageMessagesEnabled) ...[
const Divider(height: 1, indent: 16),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
secondary: const Icon(Icons.auto_awesome_outlined, size: 20),
title: Text(context.l10n.imageMessages_autoProcessTitle),
subtitle: Text(context.l10n.imageMessages_autoProcessSubtitle),
value: settingsService.settings.imageProcessAutomatically,
onChanged: (value) =>
settingsService.setImageProcessAutomatically(value),
),
_buildImageCodecModels(context),
],
], ],
); );
} }
/// The image codec, or null when it is not registered (screen tests).
ImageCodecService? _imageCodecService(BuildContext context) {
try {
return Provider.of<ImageCodecService>(context, listen: false);
} on ProviderNotFoundException {
return null;
}
}
/// Model registry for image messages: the download entry point, the
/// downloaded weights, and when the build simply cannot run the codec
/// [ImageCodecService.unavailableReason] instead of a dead download button.
Widget _buildImageCodecModels(BuildContext context) {
final ImageCodecService codec;
try {
codec = context.watch<ImageCodecService>();
} on ProviderNotFoundException {
return const SizedBox.shrink();
}
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final reason = codec.unavailableReason;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.imageMessages_modelSectionTitle,
style: textTheme.titleSmall,
),
const SizedBox(height: 6),
if (reason != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(MeshRadii.md),
),
child: Text(
reason,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
for (final spec in imageCodecPresetModels)
_buildImageCodecPreset(context, codec, spec),
for (final model in codec.availableModels)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.check_circle_outline, size: 20),
title: Text(model.name.isEmpty ? model.id : model.name),
subtitle: Text(_formatModelBytes(model.fileSizeBytes)),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: context.l10n.imageMessages_removeModel,
onPressed: codec.isBusy || codec.isDownloading
? null
: () => codec.removeModel(model),
),
),
if (codec.lastError != null) ...[
const SizedBox(height: 6),
Text(
codec.lastError!,
style: textTheme.bodySmall?.copyWith(color: scheme.error),
),
],
],
),
);
}
Widget _buildImageCodecPreset(
BuildContext context,
ImageCodecService codec,
ImageCodecModelSpec spec,
) {
final textTheme = Theme.of(context).textTheme;
final scheme = Theme.of(context).colorScheme;
final alreadyHave = codec.availableModels.any((m) => m.id == spec.id);
// downloadPresetModel throws StateError on a placeholder URL, deliberately,
// so the button must not be offerable until the weights are published.
final blocked = spec.urlsArePlaceholders;
final busy = codec.isDownloading;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.download_outlined, size: 20),
title: Text(spec.label),
subtitle: Text(
blocked
? context.l10n.imageMessages_modelNotPublished
: _formatModelBytes(spec.totalSizeBytes),
),
trailing: alreadyHave
? Text(
context.l10n.imageMessages_modelReady,
style: textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
)
: busy
? TextButton(
onPressed: codec.cancelDownload,
child: Text(context.l10n.imageMessages_cancelDownload),
)
: TextButton(
onPressed: blocked
? null
: () => _downloadImageCodecModel(context, codec, spec),
child: Text(context.l10n.imageMessages_downloadModel),
),
),
if (busy)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: LinearProgressIndicator(value: codec.downloadProgress),
),
],
),
);
}
Future<void> _downloadImageCodecModel(
BuildContext context,
ImageCodecService codec,
ImageCodecModelSpec spec,
) async {
final messenger = ScaffoldMessenger.of(context);
final failed = context.l10n.imageMessages_downloadFailed;
try {
await codec.downloadPresetModel(spec);
} on Exception catch (error) {
messenger.showSnackBar(SnackBar(content: Text('$failed $error')));
}
}
String _formatModelBytes(int bytes) {
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB';
}
if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(0)} kB';
return '$bytes B';
}
Widget _buildBatteryContent( Widget _buildBatteryContent(
BuildContext context, BuildContext context,
AppSettingsService settingsService, AppSettingsService settingsService,
@@ -2509,3 +2716,45 @@ class _TranslationLanguageDialogContentState
); );
} }
} }
/// Scrolls [child] into view exactly once, after the first frame.
///
/// A `StatefulWidget` rather than a post-frame callback in `build` because the
/// settings screen rebuilds on every `AppSettingsService` notification: a
/// callback in `build` would yank the list back to the image block every time
/// the user touched an unrelated switch.
///
/// Silently does nothing when there is no enclosing [Scrollable] (screen tests
/// that render a section in isolation) or when the element is gone by the time
/// the frame lands.
class _ScrollIntoViewOnce extends StatefulWidget {
final bool enabled;
final Widget child;
const _ScrollIntoViewOnce({required this.enabled, required this.child});
@override
State<_ScrollIntoViewOnce> createState() => _ScrollIntoViewOnceState();
}
class _ScrollIntoViewOnceState extends State<_ScrollIntoViewOnce> {
@override
void initState() {
super.initState();
if (!widget.enabled) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final scrollable = Scrollable.maybeOf(context);
if (scrollable == null) return;
Scrollable.ensureVisible(
context,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
alignment: 0.1,
);
});
}
@override
Widget build(BuildContext context) => widget.child;
}
+561 -19
View File
@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart' hide TextDirection; import 'package:intl/intl.dart' hide TextDirection;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -22,10 +24,16 @@ import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../models/image_codec_support.dart' show aeicRatePointForUi;
import '../models/translation_support.dart'; import '../models/translation_support.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart'; import '../services/chat_text_scale_service.dart';
import '../services/image_chunk_transport.dart';
import '../services/image_codec_service.dart';
import '../services/received_image_store.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../utils/lora_airtime.dart';
import '../widgets/received_image_message.dart';
import '../widgets/byte_count_input.dart'; import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/chat_zoom_wrapper.dart';
@@ -33,6 +41,9 @@ import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart'; import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import '../widgets/image_send_button.dart';
import '../widgets/image_send_codec_binding.dart';
import '../widgets/image_send_preview_sheet.dart';
import '../widgets/message_translation_button.dart'; import '../widgets/message_translation_button.dart';
import '../widgets/message_status_icon.dart'; import '../widgets/message_status_icon.dart';
import '../widgets/radio_stats_entry.dart'; import '../widgets/radio_stats_entry.dart';
@@ -41,6 +52,7 @@ import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart'; import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart'; import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart'; import '../widgets/mesh_ui.dart';
import 'app_settings_screen.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import 'region_management_screen.dart'; import 'region_management_screen.dart';
@@ -71,6 +83,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
bool _communitiesLoaded = false; bool _communitiesLoaded = false;
/// Per-screen image-id allocator. The id is 8 bits and the reassembly key also
/// carries the sender prefix and channel, so a per-screen allocator is enough
/// to keep two images in the same channel apart.
final ImageIdAllocator _imageIds = ImageIdAllocator();
/// Chunks acked so far / total, while an image send is in flight. Both 0 when
/// nothing is being sent.
int _imageSendSent = 0;
int _imageSendTotal = 0;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false; bool _channelSkipNextBottomSnap = false;
@@ -373,8 +395,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: Consumer<MeshCoreConnector>( child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
final imageRows = _receivedImageRows(context);
if (messages.isEmpty) { if (messages.isEmpty && imageRows.isEmpty) {
return EmptyState( return EmptyState(
icon: widget.channel.isPublicChannel icon: widget.channel.isPublicChannel
? Icons.public ? Icons.public
@@ -390,16 +413,22 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
// Reverse messages so newest appear at bottom with reverse: true // Images are not ChannelMessages: they arrive as GRP_DATA
final reversedMessages = messages.reversed.toList(); // chunks and are owned by ReceivedImageStore, so the two
final itemCount = // sources are merged here in timestamp order and the list
reversedMessages.length + (_isLoadingOlder ? 1 : 0); // below indexes rows, not messages.
final rows = <_ChannelChatRow>[
for (final message in messages) _ChannelChatRow(message: message),
...imageRows,
]..sort((a, b) => a.timestamp.compareTo(b.timestamp));
// Reverse rows so newest appear at bottom with reverse: true
final reversedRows = rows.reversed.toList();
final itemCount = reversedRows.length + (_isLoadingOlder ? 1 : 0);
// Prune stale keys (deleted/cleared messages) to avoid // Prune stale keys (deleted/cleared messages) to avoid
// unbounded growth. // unbounded growth.
final liveIds = reversedMessages final liveIds = reversedRows.map((r) => r.id).toSet();
.map((m) => m.messageId)
.toSet();
_messageKeys.removeWhere((id, _) => !liveIds.contains(id)); _messageKeys.removeWhere((id, _) => !liveIds.contains(id));
// Two messages can collide on messageId (same ms + name/text // Two messages can collide on messageId (same ms + name/text
@@ -408,8 +437,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// no two widgets share one GlobalKey. // no two widgets share one GlobalKey.
final seenIds = <String>{}; final seenIds = <String>{};
final keyedIndices = <int>{}; final keyedIndices = <int>{};
for (var i = 0; i < reversedMessages.length; i++) { for (var i = 0; i < reversedRows.length; i++) {
if (seenIds.add(reversedMessages[i].messageId)) { if (seenIds.add(reversedRows[i].id)) {
keyedIndices.add(i); keyedIndices.add(i);
} }
} }
@@ -447,12 +476,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
); );
} }
final messageIndex = index; final row = reversedRows[index];
final message = reversedMessages[messageIndex];
final GlobalKey messageKey; final GlobalKey messageKey;
if (keyedIndices.contains(messageIndex)) { if (keyedIndices.contains(index)) {
messageKey = _messageKeys.putIfAbsent( messageKey = _messageKeys.putIfAbsent(
message.messageId, row.id,
GlobalKey.new, GlobalKey.new,
); );
} else { } else {
@@ -460,7 +488,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
final isUnreadAnchor = final isUnreadAnchor =
_unreadDividerMessageId != null && _unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId; row.id == _unreadDividerMessageId;
return Container( return Container(
key: messageKey, key: messageKey,
child: Builder( child: Builder(
@@ -469,10 +497,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.select<ChatTextScaleService, double>( .select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
final bubble = _buildMessageBubble( final message = row.message;
message, final bubble = message != null
textScale, ? _buildMessageBubble(message, textScale)
); : _buildImageBubble(row.image!, textScale);
if (isUnreadAnchor) { if (isUnreadAnchor) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1035,6 +1063,433 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
/// The image codec, or null when it is not registered (screen tests).
ImageCodecService? get _imageCodec {
try {
return context.read<ImageCodecService>();
} on ProviderNotFoundException {
return null;
}
}
/// Whether the image codec model is currently downloading.
///
/// The image button must stay hidden while the model downloads: an encode
/// cannot start until the weights are on disk, and the preview sheet would
/// have nothing to show but a spinner.
bool get _imageCodecDownloading {
try {
return context.watch<ImageCodecService>().isDownloading;
} on ProviderNotFoundException {
return false;
}
}
/// Takes no [BuildContext]: it uses the [State]'s own, so the `mounted`
/// checks around each `await` are the ones that actually govern it.
Future<void> _showImageSendPreview() async {
final codec = _imageCodec;
if (codec == null) return;
final connector = context.read<MeshCoreConnector>();
// The picked bytes are kept past the preview on purpose: the sender's own
// bubble renders from them (see [_registerOutgoingImage]). The bitstream
// cannot serve turning it back into pixels is a ~2.16 GiB, ~1 s decode
// of an image the sender is already holding.
final XFile? picked;
try {
picked = await ImagePicker().pickImage(
source: ImageSource.gallery,
// The codec centre-crops to 512x512 anyway, so there is nothing to
// gain from decoding a 12 MP original but stay well above 512 so the
// crop still has detail to work with.
maxWidth: 2048,
maxHeight: 2048,
);
} on Exception catch (e) {
debugPrint('image pick failed: $e');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
);
return;
}
if (picked == null) return; // cancelled at the picker
final Uint8List sourceBytes;
final int originalFileBytes;
try {
sourceBytes = await picked.readAsBytes();
// XFile.length() is the on-disk size what the sheet shows against the
// transmitted size and unlike dart:io it also works on web.
originalFileBytes = await picked.length();
} on Exception catch (e) {
debugPrint('image read failed: $e');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
);
return;
}
if (!mounted) return;
final result = await showImageSendPreviewSheet(
context: context,
imageBytes: sourceBytes,
originalFileBytes: originalFileBytes,
codec: codec,
);
if (result == null) return; // cancelled at the preview
if (!mounted) return;
await _sendImage(connector, result, sourceBytes: sourceBytes);
}
/// Chunks [result] and puts it on the channel as GRP_DATA packets.
///
/// Chunk geometry, CRC and parity all come from [buildImageChunks] \u2014 the
/// single source of truth for the wire format \u2014 and the blobs go out through
/// [MeshCoreConnector.sendImageChunks] as one list, so the connector owns the
/// inter-chunk pacing and the whole image sits inside one scoped send.
///
/// [sourceBytes] is the original picked file. It is only ever used to draw
/// the sender's own bubble; nothing about the transmission depends on it.
Future<void> _sendImage(
MeshCoreConnector connector,
ImageSendPreviewResult result, {
required Uint8List sourceBytes,
}) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
if (!connector.supportsChannelData) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.imageSend_deviceUnsupported)),
);
return;
}
final senderPrefix = connector.imageSenderPrefix;
if (senderPrefix == null) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.imageSend_deviceUnsupported)),
);
return;
}
final ImageChunkSet chunkSet;
try {
chunkSet = buildImageChunks(
payload: result.payload,
metadata: ImageStreamMetadata(
rate: result.rate,
squareSize: kImageCodecSquareSize,
// The codec stretched the whole frame into a square, which the
// receiver cannot undo from pixels alone. Naming the source shape
// here costs no extra bytes -- it rides in spare bits of the metadata
// byte -- and lets the receiver letterbox back.
aspectCode: await _aspectCodeOf(sourceBytes),
),
senderPrefix: senderPrefix,
imgId: _imageIds.next(),
parity: result.includeParity,
);
} on ArgumentError {
messenger.showSnackBar(
SnackBar(content: Text(l10n.imageSend_tooLarge)),
);
return;
}
// Pace on the measured per-packet airtime when the radio parameters are
// known; fall back to the base gap when they are not, rather than
// hammering the channel back-to-back.
final total = result.airtime;
final perPacket = total == null
? Duration.zero
: Duration(
microseconds: total.inMicroseconds ~/
(result.packetCount == 0 ? 1 : result.packetCount),
);
setState(() {
_imageSendTotal = chunkSet.blobs.length;
_imageSendSent = 0;
});
try {
final sent = await connector.sendImageChunks(
chunkSet.blobs,
channelIndex: widget.channel.index,
interChunkDelay: imageSendChunkGap(perPacket),
onProgress: (sentChunks, totalChunks) {
if (!mounted) return;
setState(() => _imageSendSent = sentChunks);
},
);
// Register before the snack bar, so the bubble is on screen by the time
// the confirmation appears rather than a frame behind it.
if (sent) {
await _registerOutgoingImage(
result: result,
chunkSet: chunkSet,
senderPrefix: senderPrefix,
sourceBytes: sourceBytes,
);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
sent
? l10n.imageSend_sentConfirmation(chunkSet.blobs.length)
: l10n.imageSend_deviceUnsupported,
),
),
);
} on Exception catch (error) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.imageSend_sendFailed('$error'))),
);
} finally {
if (mounted) {
setState(() {
_imageSendTotal = 0;
_imageSendSent = 0;
});
}
}
}
/// Puts the image the user just sent into [ReceivedImageStore] so it appears
/// in their own transcript.
///
/// GRP_DATA is not echoed back to the sender and an image is not a
/// [ChannelMessage], so without this the sender sees a "sent" snack bar and
/// an otherwise empty conversation. The entry lands in `decoded` directly
/// an outgoing image is never queued for inference, so showing your own
/// photo costs no model memory.
///
/// Best effort throughout: a failure here has already been preceded by a
/// successful transmission, so it must not turn into an error the user sees.
Future<void> _registerOutgoingImage({
required ImageSendPreviewResult result,
required ImageChunkSet chunkSet,
required int senderPrefix,
required Uint8List sourceBytes,
}) async {
final ReceivedImageStore store;
try {
store = context.read<ReceivedImageStore>();
} on ProviderNotFoundException {
return; // screen test without the store
}
final previewPng = await _squarePreviewPng(sourceBytes);
if (previewPng == null) return;
try {
await store.registerOutgoing(
channelIndex: widget.channel.index,
// The same 16 bits the chunk header carries, so the entry keys the
// same way an inbound one would.
senderPrefix: senderPrefix,
imgId: chunkSet.imgId,
previewPng: previewPng,
rate: aeicRatePointForUi(result.rate),
chunkCount: chunkSet.dataChunkCount,
);
} on Exception catch (error) {
debugPrint('outgoing image registration failed: $error');
}
}
/// The [kImageAspectCodes] entry matching [imageBytes]'s shape.
///
/// Decodes only to read the dimensions. Falls back to "unknown" (rendered
/// square) rather than guessing, because asserting the wrong shape would
/// letterbox the receiver's copy incorrectly and look like a codec bug.
static Future<int> _aspectCodeOf(Uint8List imageBytes) async {
ui.Image? image;
try {
final codec = await ui.instantiateImageCodec(imageBytes);
image = (await codec.getNextFrame()).image;
return imageAspectCodeFor(image.width, image.height);
} on Exception catch (error) {
debugPrint('aspect probe failed: $error');
return kImageAspectUnknown;
} finally {
image?.dispose();
}
}
/// [imageBytes] stretched to 512x512, as PNG.
///
/// This is what the preview sheet showed (`BoxFit.fill` on a 1:1 box is
/// exactly the stretch the codec applies) and what the codec actually
/// encoded, so the sender's bubble matches both. Deliberately not a decode of the
/// bitstream: that would cost ~2.16 GiB and ~1 s to reproduce, less well, an
/// image this device is already holding in memory.
///
/// Returns null rather than throwing on an undecodable source the send has
/// already happened by the time this runs.
static Future<Uint8List?> _squarePreviewPng(Uint8List imageBytes) async {
ui.Image? source;
ui.Image? square;
try {
final codec = await ui.instantiateImageCodec(imageBytes);
source = (await codec.getNextFrame()).image;
final dst = kImageCodecSquareSize.toDouble();
final recorder = ui.PictureRecorder();
ui.Canvas(recorder).drawImageRect(
source,
// Whole frame, matching _toSquareRgb in image_codec_service.dart. If
// one of these two is a crop and the other a stretch, the sender's
// bubble silently stops matching what the receiver sees.
ui.Rect.fromLTWH(
0,
0,
source.width.toDouble(),
source.height.toDouble(),
),
ui.Rect.fromLTWH(0, 0, dst, dst),
ui.Paint()..filterQuality = ui.FilterQuality.medium,
);
square = await recorder.endRecording().toImage(
kImageCodecSquareSize,
kImageCodecSquareSize,
);
final data = await square.toByteData(format: ui.ImageByteFormat.png);
return data?.buffer.asUint8List();
} on Exception catch (error) {
debugPrint('outgoing image preview render failed: $error');
return null;
} finally {
source?.dispose();
square?.dispose();
}
}
/// Received/sent AEIC images for this channel, as transcript rows.
///
/// Returns nothing when [ReceivedImageStore] is not registered, so a screen
/// test without the provider still renders.
/// [context] must be the context of the element currently building (the
/// `Consumer` builder's, not the `State`'s) `watch` asserts on that.
List<_ChannelChatRow> _receivedImageRows(BuildContext context) {
final ReceivedImageStore store;
try {
store = context.watch<ReceivedImageStore>();
} on ProviderNotFoundException {
return const <_ChannelChatRow>[];
}
return <_ChannelChatRow>[
for (final entry in store.entries)
if (entry.channelIndex == widget.channel.index)
_ChannelChatRow(image: entry),
];
}
/// Bubble for one AEIC image, following the GIF-message precedent: minimal
/// chrome, the renderer owns every state (receiving / decoding / failed) and
/// the mandatory AI-reconstruction label.
Widget _buildImageBubble(ReceivedImageEntry entry, double textScale) {
final scheme = Theme.of(context).colorScheme;
final isOutgoing = entry.isOutgoing;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isOutgoing) ...[
_buildAvatar(_imageSenderLabel(entry), textScale),
const SizedBox(width: 6),
],
Flexible(
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isOutgoing
? MeshPalette.me
: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(MeshRadii.lg),
border: Border.all(
color: isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant,
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing)
Padding(
padding: const EdgeInsets.only(left: 8, top: 4, bottom: 4),
child: Text(
_imageSenderLabel(entry),
style: TextStyle(
fontSize: 13 * textScale,
fontWeight: FontWeight.w700,
color: textColor,
),
),
),
ReceivedImageMessage(
streamId: entry.streamId,
isOutgoing: isOutgoing,
fallbackTextColor: textColor,
strings: _receivedImageStrings(context),
// The placeholder's "no model installed" tap lands here.
// `focusImageMessages` scrolls straight to the image block
// it sits below six other sections, so the plain screen
// would open at the top with no sign of what was tapped for.
onOpenCodecSettings: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
const AppSettingsScreen(focusImageMessages: true),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4),
child: Text(
_formatTime(context, entry.firstSeen),
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
),
],
),
),
),
],
),
);
}
/// GRP_DATA carries no sender name only the 2-byte public-key prefix the
/// chunk header stamps so resolve that against the contact list and fall
/// back to the raw prefix only when nobody matches.
///
/// Two bytes is 65,536 values, so a collision between two known contacts is
/// possible. When it happens the sender is genuinely ambiguous and naming
/// either one would be a guess, so the prefix is shown instead.
String _imageSenderLabel(ReceivedImageEntry entry) {
final hex = entry.senderPrefix.toRadixString(16).padLeft(4, '0');
final connector = context.read<MeshCoreConnector>();
if (entry.isOutgoing) return connector.selfName ?? context.l10n.receivedImage_senderPrefix(hex);
final matches = connector.contacts
.where((c) => c.publicKeyHex.toLowerCase().startsWith(hex))
.toList();
if (matches.length == 1) return matches.single.name;
return context.l10n.receivedImage_senderPrefix(hex);
}
void _showGifPicker(BuildContext context) { void _showGifPicker(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -1105,6 +1560,37 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
/// "Sending image — packet 2 of 3", above the composer.
///
/// A per-chunk figure rather than a spinner because an image occupies the
/// channel for seconds and the user needs to see it advancing.
Widget _buildImageSendProgress(ColorScheme scheme) {
final total = _imageSendTotal;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.imageSend_sendingProgress(_imageSendSent, total),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: total == 0 ? null : _imageSendSent / total,
),
],
),
);
}
Widget _buildMessageComposer() { Widget _buildMessageComposer() {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName); final maxBytes = maxChannelMessageBytes(connector.selfName);
@@ -1122,6 +1608,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return _buildReplyBanner(textScale); return _buildReplyBanner(textScale);
}, },
), ),
if (_imageSendTotal > 0) _buildImageSendProgress(scheme),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: scheme.surface, color: scheme.surface,
@@ -1146,6 +1633,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
languageCode: settings.translationTargetLanguageCode, languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions, onPressed: _showTranslationOptions,
), ),
if (settings.imageMessagesEnabled && !_imageCodecDownloading)
ImageSendButton(
// Gated on the codec, not just the setting. The preview
// sheet explains why a send is impossible, but a fully
// live button in a build that cannot encode invites the
// tap that produces that explanation.
enabled:
_imageCodec?.availability ==
ImageCodecAvailability.ready,
onPressed: () => _showImageSendPreview(),
),
Expanded( Expanded(
child: ValueListenableBuilder<TextEditingValue>( child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController, valueListenable: _textController,
@@ -1873,3 +2371,47 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
); );
} }
} }
/// One row of the channel transcript.
///
/// Exactly one of [message] / [image] is non-null. Images cannot be
/// `ChannelMessage`s: they arrive as GRP_DATA chunks with no text frame behind
/// them, are keyed on (sender prefix, img id, channel) rather than a message id,
/// and are persisted by [ReceivedImageStore] instead of the channel message
/// store.
@immutable
class _ChannelChatRow {
final ChannelMessage? message;
final ReceivedImageEntry? image;
const _ChannelChatRow({this.message, this.image});
DateTime get timestamp => message?.timestamp ?? image!.firstSeen;
/// Stable identity for the scroll-to-message [GlobalKey] map. The `aeic:`
/// prefix keeps a stream id from ever colliding with a message id.
String get id => message?.messageId ?? 'aeic:${image!.streamId}';
}
/// [ReceivedImageStrings] built from the app's localizations.
///
/// The R6 badge and caption are deliberately absent from this class they are
/// private consts in `received_image_message.dart` and must stay mandatory.
ReceivedImageStrings _receivedImageStrings(BuildContext context) {
final l10n = context.l10n;
return ReceivedImageStrings(
incoming: l10n.receivedImage_incoming,
queued: l10n.receivedImage_queued,
tapToDecode: l10n.receivedImage_tapToDecode,
awaiting: l10n.receivedImage_awaiting,
tapToProcess: l10n.receivedImage_tapToProcess,
decoding: l10n.receivedImage_decoding,
incomplete: l10n.receivedImage_incomplete,
corrupt: l10n.receivedImage_corrupt,
decoderMissing: l10n.receivedImage_decoderMissing,
evicted: l10n.receivedImage_evicted,
retry: l10n.receivedImage_retry,
decodeAgain: l10n.receivedImage_decodeAgain,
openSettings: l10n.receivedImage_openSettings,
);
}
+11
View File
@@ -47,6 +47,14 @@ import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart'; import '../widgets/mesh_ui.dart';
import 'telemetry_screen.dart'; import 'telemetry_screen.dart';
// Image messages are deliberately absent from this screen, and there is no
// flag to flip: the AEIC wire format is `CMD_SEND_CHANNEL_DATA` (62) /
// GRP_DATA 0x06, which addresses a channel index rather than a contact key,
// and the companion protocol has no direct-message equivalent (there is no
// CMD_SEND_DATA see `meshcore_protocol.dart`). Sending a private photo on
// channel 0 to reach one contact would broadcast it to everyone on that
// channel. Channel chats keep the feature; see `channel_chat_screen.dart`.
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final Contact contact; final Contact contact;
final int initialUnreadCount; final int initialUnreadCount;
@@ -480,6 +488,9 @@ class _ChatScreenState extends State<ChatScreen> {
languageCode: settings.translationTargetLanguageCode, languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions, onPressed: _showTranslationOptions,
), ),
// No image button here: the image wire format is GRP_DATA, a
// channel primitive with no DM equivalent (see the note at the
// top of this file).
Expanded( Expanded(
child: ValueListenableBuilder<TextEditingValue>( child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController, valueListenable: _textController,
+52
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/app_settings.dart'; import '../models/app_settings.dart';
import '../models/image_codec_support.dart';
import '../models/translation_support.dart'; import '../models/translation_support.dart';
import '../storage/prefs_manager.dart'; import '../storage/prefs_manager.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
@@ -280,6 +281,57 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(jumpToOldestUnread: value)); await updateSettings(_settings.copyWith(jumpToOldestUnread: value));
} }
Future<void> setImageMessagesEnabled(bool value) async {
await updateSettings(_settings.copyWith(imageMessagesEnabled: value));
}
/// See [AppSettings.imageProcessAutomatically]. `main.dart` mirrors this into
/// `ReceivedImageStore.processAutomatically` on every settings change; the
/// store applies it to future arrivals only, so flipping it does not
/// retroactively decode a backlog.
Future<void> setImageProcessAutomatically(bool value) async {
await updateSettings(_settings.copyWith(imageProcessAutomatically: value));
}
// ---- neural image codec (AEIC-SE) ---------------------------------------
Future<void> setImageCodecEnabled(bool value) async {
await updateSettings(_settings.copyWith(imageCodecEnabled: value));
}
Future<void> setImageCodecSelectedModelId(String? value) async {
await updateSettings(_settings.copyWith(imageCodecSelectedModelId: value));
}
Future<void> setImageCodecModelSourceUrl(String? value) async {
await updateSettings(_settings.copyWith(imageCodecModelSourceUrl: value));
}
/// [value] is an [AeicRatePoint.wireValue], not an enum index.
Future<void> setImageCodecRatePoint(int value) async {
await updateSettings(_settings.copyWith(imageCodecRatePoint: value));
}
Future<void> setImageCodecDownloadedModels(
List<ImageCodecModelRecord> value,
) async {
await updateSettings(_settings.copyWith(imageCodecDownloadedModels: value));
}
/// Writes the whole block in one persist, which is what
/// `ImageCodecService` does on every preference change.
Future<void> setImageCodecPreferences(ImageCodecPreferences value) async {
await updateSettings(
_settings.copyWith(
imageCodecEnabled: value.enabled,
imageCodecSelectedModelId: value.selectedModelId,
imageCodecModelSourceUrl: value.modelSourceUrl,
imageCodecRatePoint: value.ratePoint,
imageCodecDownloadedModels: value.downloadedModels,
),
);
}
Future<void> setTranslationEnabled(bool value) async { Future<void> setTranslationEnabled(bool value) async {
await updateSettings(_settings.copyWith(translationEnabled: value)); await updateSettings(_settings.copyWith(translationEnabled: value));
} }
+287
View File
@@ -0,0 +1,287 @@
// Parser for the AEIC CDF table file (`aeic_cdf_ft32.bin`) that ships inside the
// image-codec model bundle.
//
// The file is produced by `exp/export_golden.py::write_table_file` in the AEIC
// research repo. Layout, all little-endian, tightly packed, no padding:
//
// off type field
// 0 char[8] magic = "AEICCDF\x01"
// 8 u32 version = 1
// 12 u32 precision = 16
// 16 u32 bypassPrecision = 2
// 20 u32 streamParts = 2
// 24 u32 numGroups = 2
// 28 ... group blocks (group 0 = z, group 1 = y)
// ... index-quantizer block
// char[4] "END\0"
//
// group block:
// u32 numCdfs R
// u32 cdfWidth W
// i32[R] cdfLength row r is valid only on [0, cdfLength[r])
// i32[R] offset symbol offset for row r
// i32[R*W] quantizedCdf row-major
//
// index-quantizer block:
// char[4] "IDXP"
// f64 logScaleMin
// f64 logScaleStep
// u32 scalesLevels
// f32 scaleThreshold (scale below this => index -1)
// f32 scaleFloor
// f32[N] scaleTable
library;
import 'dart:typed_data';
/// Thrown when the CDF table file is malformed or of an unsupported version.
class EntropyTableFormatException implements Exception {
EntropyTableFormatException(this.message);
final String message;
@override
String toString() => 'EntropyTableFormatException: $message';
}
/// One CDF group (z = entropy bottleneck, y = Gaussian conditional).
class CdfGroup {
CdfGroup({
required this.numCdfs,
required this.cdfWidth,
required this.cdfLength,
required this.offset,
required this.quantizedCdf,
});
/// Number of CDF rows (`R`).
final int numCdfs;
/// Stride of a row in [quantizedCdf] (`W`). Entries beyond
/// `cdfLength[row]` are padding and must never be read.
final int cdfWidth;
/// Valid length of each row; `cdfLength[r] - 2` is the escape symbol.
final Int32List cdfLength;
/// Symbol offset per row.
final Int32List offset;
/// Row-major CDF table, `R * W` entries.
final Int32List quantizedCdf;
/// Value at `[row][col]`.
int cdfAt(int row, int col) => quantizedCdf[row * cdfWidth + col];
}
/// Constants needed to reproduce `my_build_indexes` (the scale -> CDF-row
/// quantizer). Carried for completeness; the shipping encoder gets its index
/// arrays straight out of the ONNX graph instead of recomputing them here,
/// because Dart's `log` is not bit-identical to ORT's float32 `Log`.
class IndexQuantizerParams {
IndexQuantizerParams({
required this.logScaleMin,
required this.logScaleStep,
required this.scalesLevels,
required this.scaleThreshold,
required this.scaleFloor,
required this.scaleTable,
});
final double logScaleMin;
final double logScaleStep;
final int scalesLevels;
final double scaleThreshold;
final double scaleFloor;
final Float32List scaleTable;
}
/// The parsed contents of `aeic_cdf_ft32.bin`.
class EntropyTables {
EntropyTables({
required this.version,
required this.precision,
required this.bypassPrecision,
required this.streamParts,
required this.groups,
required this.indexQuantizer,
});
static const List<int> magic = <int>[
0x41, 0x45, 0x49, 0x43, 0x43, 0x44, 0x46, 0x01, // "AEICCDF\x01"
];
/// Format version. Only version 1 is understood.
final int version;
/// rANS probability precision in bits (16).
final int precision;
/// Bits per bypass symbol (2).
final int bypassPrecision;
/// Number of interleaved rANS sub-streams the bitstream is split into (2).
final int streamParts;
/// CDF groups in file order: index 0 = z, index 1 = y.
final List<CdfGroup> groups;
final IndexQuantizerParams indexQuantizer;
/// The `z` (entropy bottleneck) group.
CdfGroup get zGroup => groups[0];
/// The `y` (Gaussian conditional) group.
CdfGroup get yGroup => groups[1];
/// Parses the table file. Throws [EntropyTableFormatException] on any
/// structural problem.
static EntropyTables parse(Uint8List bytes) {
if (bytes.length < 32) {
throw EntropyTableFormatException('file too short (${bytes.length} B)');
}
for (var i = 0; i < magic.length; i++) {
if (bytes[i] != magic[i]) {
throw EntropyTableFormatException('bad magic at byte $i');
}
}
final ByteData bd = ByteData.view(
bytes.buffer,
bytes.offsetInBytes,
bytes.lengthInBytes,
);
var off = 8;
int u32() {
_need(bytes, off, 4);
final int v = bd.getUint32(off, Endian.little);
off += 4;
return v;
}
final int version = u32();
if (version != 1) {
throw EntropyTableFormatException('unsupported version $version');
}
final int precision = u32();
final int bypassPrecision = u32();
final int streamParts = u32();
final int numGroups = u32();
if (precision <= 0 || precision > 16) {
throw EntropyTableFormatException('bad precision $precision');
}
if (bypassPrecision <= 0 || bypassPrecision >= precision) {
throw EntropyTableFormatException('bad bypassPrecision $bypassPrecision');
}
if (streamParts < 1 || streamParts > 16) {
throw EntropyTableFormatException('bad streamParts $streamParts');
}
if (numGroups < 1 || numGroups > 64) {
throw EntropyTableFormatException('bad numGroups $numGroups');
}
Int32List i32(int count) {
_need(bytes, off, count * 4);
final Int32List out = Int32List(count);
var p = off;
for (var i = 0; i < count; i++) {
out[i] = bd.getInt32(p, Endian.little);
p += 4;
}
off = p;
return out;
}
final List<CdfGroup> groups = <CdfGroup>[];
for (var g = 0; g < numGroups; g++) {
final int r = u32();
final int w = u32();
if (r <= 0 || w <= 0 || r > 1 << 20 || w > 1 << 24) {
throw EntropyTableFormatException('group $g has bad shape ${r}x$w');
}
final Int32List cdfLength = i32(r);
final Int32List offset = i32(r);
final Int32List cdf = i32(r * w);
for (var row = 0; row < r; row++) {
final int n = cdfLength[row];
if (n < 2 || n > w) {
throw EntropyTableFormatException(
'group $g row $row has cdfLength $n (width $w)',
);
}
}
groups.add(
CdfGroup(
numCdfs: r,
cdfWidth: w,
cdfLength: cdfLength,
offset: offset,
quantizedCdf: cdf,
),
);
}
_need(bytes, off, 4);
if (bytes[off] != 0x49 ||
bytes[off + 1] != 0x44 ||
bytes[off + 2] != 0x58 ||
bytes[off + 3] != 0x50) {
throw EntropyTableFormatException('missing IDXP block at byte $off');
}
off += 4;
_need(bytes, off, 8 + 8 + 4 + 4 + 4);
final double logScaleMin = bd.getFloat64(off, Endian.little);
final double logScaleStep = bd.getFloat64(off + 8, Endian.little);
final int levels = bd.getUint32(off + 16, Endian.little);
final double scaleThreshold = bd.getFloat32(off + 20, Endian.little);
final double scaleFloor = bd.getFloat32(off + 24, Endian.little);
off += 28;
if (levels <= 0 || levels > 1 << 20) {
throw EntropyTableFormatException('bad scalesLevels $levels');
}
_need(bytes, off, levels * 4);
final Float32List scaleTable = Float32List(levels);
for (var i = 0; i < levels; i++) {
scaleTable[i] = bd.getFloat32(off + i * 4, Endian.little);
}
off += levels * 4;
_need(bytes, off, 4);
if (bytes[off] != 0x45 ||
bytes[off + 1] != 0x4E ||
bytes[off + 2] != 0x44 ||
bytes[off + 3] != 0x00) {
throw EntropyTableFormatException('missing END trailer at byte $off');
}
off += 4;
if (off != bytes.length) {
throw EntropyTableFormatException(
'trailing data: parsed $off of ${bytes.length} bytes',
);
}
return EntropyTables(
version: version,
precision: precision,
bypassPrecision: bypassPrecision,
streamParts: streamParts,
groups: groups,
indexQuantizer: IndexQuantizerParams(
logScaleMin: logScaleMin,
logScaleStep: logScaleStep,
scalesLevels: levels,
scaleThreshold: scaleThreshold,
scaleFloor: scaleFloor,
scaleTable: scaleTable,
),
);
}
static void _need(Uint8List bytes, int off, int count) {
if (off < 0 || off + count > bytes.length) {
throw EntropyTableFormatException(
'truncated file: needed $count bytes at $off of ${bytes.length}',
);
}
}
}
File diff suppressed because it is too large Load Diff
+871
View File
@@ -0,0 +1,871 @@
import 'dart:math' as math;
import 'dart:typed_data' show Int16List;
import 'package:flutter/foundation.dart';
import 'package:flutter_onnxruntime/flutter_onnxruntime.dart';
import '../models/image_codec_support.dart';
import '../utils/app_logger.dart';
import '../widgets/image_send_codec_binding.dart' show kImageCodecSquareSize;
import 'entropy_tables.dart';
import 'image_codec_entropy.dart';
import 'rans_coder.dart';
/// ===========================================================================
/// THE NATIVE INFERENCE SEAM.
/// ===========================================================================
///
/// This is the ONLY place where real neural-network execution belongs.
///
/// [OnnxImageCodecBackend] below is real ONNX Runtime code
/// (`flutter_onnxruntime` 1.8.3 / ORT 1.23.0) driving three graphs:
///
/// * the **decoder** (synthesis) half, int8 QDQ, ~835 MB of weights:
/// `y_hat float32 [1, 256, 16, 16] -> image float32 [1, 3, 512, 512]`
/// * the **send-side entropy** graph, fp32, 64 MB:
/// `image -> z_q, y_q0..3, scales0..3`, whose integer outputs feed the rANS
/// coder. Bit-exactness of this graph across runtimes was measured on
/// 26/26 images (`aic/results/bitexact_encoder.md`).
/// * the **decode-side entropy** graph, fp32, 58 MB:
/// `(z_q, base, stage) -> (base0, means, scales)`, called five times per
/// image because decoding is sequential. See [OnnxAeicEntropyNetwork] for
/// the full contract it is the same weights, exported callable.
///
/// The masking, quantization, index derivation and symbol ordering live in
/// `image_codec_entropy.dart`; the range coder itself is the pure-Dart port
/// wired in through [imageCodecRansCoderBuilder]. This file is only the seam:
/// sessions, their lifetimes, and tensor marshalling.
///
/// ## Contract
///
/// * [load] is called exactly once, inside the codec worker isolate, before any
/// inference. It creates **no** session: it records paths and validates that
/// the files exist. Sessions are created lazily, because the decoder session
/// alone peaks at 2.16 GiB and a user who only ever sends photos must never
/// pay it.
/// * [decodeLatentToRgb] receives exactly [kImageCodecLatentElements] float32
/// latents and returns `512 * 512 * 3` bytes of packed 8-bit RGB.
/// * [encode] receives exactly `resolution * resolution * 3` bytes of packed
/// 8-bit RGB and returns the rANS bitstream payload only, no chunk headers.
/// * [decode] receives that bitstream and returns packed 8-bit RGB.
/// * [onProgress] is optional and reports 0.0..1.0.
/// * [shouldCancel] is polled at stage boundaries. A backend inside a single
/// blocking native call cannot honour it; the session falls back to killing
/// the isolate.
/// * [dispose] must release the native sessions and the memory they hold.
///
/// ## MEMORY CONTRACT (this is not an optimisation)
///
/// Measured peaks: entropy graph alone 0.35 GiB, image decoder alone 2.16 GiB,
/// both resident 2.44 GiB.
///
/// * `encode()` creates the SEND-side entropy session, runs, and **keeps** it.
/// Sending a second photo then costs nothing. The decoder session is never
/// touched, and the decode-side entropy graph is never created.
/// * `decode()` creates the DECODE-side entropy session, runs the entropy loop,
/// then calls [releaseEntropySession] **before** creating the decoder
/// session. Holding both at once means 2.44 GiB on a phone. The release is
/// mandatory.
/// * Only one entropy direction is ever resident: no operation needs both.
/// * Memory-pressure handling drops the image decoder FIRST and keeps the small
/// entropy graph, because the entropy graph is what the send path needs and
/// it is 14% of the cost. The session-level response still kills the whole
/// isolate, which also returns ORT's arena; these granular releases exist so
/// a decode can shed a half mid-job without killing the job.
///
/// ## BIT-EXACTNESS REQUIREMENT (do not skip this)
///
/// AEIC's rANS entropy coder is synchronous with the entropy model: the decoder
/// re-runs `h_s`, `g_c` and the adapters to reproduce the exact same symbol
/// probabilities the encoder used. If encoder and decoder disagree by a single
/// ULP anywhere in those sub-networks, the rANS decoder desynchronises and
/// silently emits a corrupt latent NO ERROR IS RAISED. Observed during
/// validation: a 2.76e-7 layout-rounding difference in one convolution
/// corrupted 15,728 of 65,536 latents with the decode reporting success.
///
/// Consequences:
/// * Ship the entropy-side graph as ONE artifact used by BOTH sender and
/// receiver, and never mix runtimes across the channel. ORT-to-ORT is
/// deterministic within a build; ORT-to-PyTorch is not safe.
/// * Keep `h_s`, `g_c` and the adapters in fp32. Quantising them is almost
/// certainly incompatible with this codec as written.
/// * Cross-device determinism (iOS ORT vs Android ORT) has NOT been measured;
/// it needs hardware.
///
/// Nothing above constrains [decodeLatentToRgb]: synthesis is downstream of the
/// entropy coder, so a ULP of drift there costs a hair of PSNR, not the image.
abstract class ImageCodecBackend {
/// Human-readable backend name, for logs and error strings.
String get name;
/// Whether [encode]/[decode] work, i.e. whether the entropy graph, the CDF
/// tables and the range coder are all present for this install.
bool get supportsBitstreamCodec;
/// Records the bundle and validates it. Creates no session.
///
/// Takes an [ImageCodecBundle]. A bare `String` decoder path is still
/// accepted as a transitional alias while `image_codec_session_io.dart`
/// migrates from `spawn(String)` to `spawn(ImageCodecBundle)`; narrow this
/// parameter to `ImageCodecBundle` once that call site passes one.
Future<void> load(Object bundle);
/// Runs the synthesis half: latent -> packed 8-bit RGB.
Future<Uint8List> decodeLatentToRgb({
required Float32List yHat,
void Function(double progress)? onProgress,
bool Function()? shouldCancel,
});
Future<Uint8List> encode({
required Uint8List rgbBytes,
required AeicRatePoint ratePoint,
required int resolution,
void Function(double progress)? onProgress,
bool Function()? shouldCancel,
});
Future<Uint8List> decode({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
void Function(double progress)? onProgress,
bool Function()? shouldCancel,
});
/// Drops the ~2.16 GiB synthesis session, keeping the entropy half.
Future<void> releaseDecoderSession();
/// Drops the 67 MB entropy session, keeping the synthesis half.
Future<void> releaseEntropySession();
Future<void> dispose();
}
/// Compile-time answer to "can this build turn bytes into a picture?".
///
/// **Currently `false`.** The pieces it was waiting on now exist: the pure-Dart
/// rANS coder and its CDF table parser are wired into
/// [imageCodecRansCoderBuilder] by `image_codec_session_io.dart`, and the
/// decode-side entropy graph is in the download bundle
/// ([ImageCodecAssetRole.entropyDecodeGraph]).
///
/// ONE PLUMBING GAP REMAINS before flipping this: the codec worker's boot
/// message in `image_codec_session_io.dart` is a positional list that does not
/// yet carry `ImageCodecBundle.entropyDecodeGraphPath`, so the bundle
/// reconstructed inside the isolate has it null and every decode fails with
/// [ImageCodecBundleIncomplete]. Encoding is unaffected. Flip this flag in the
/// same commit that closes that gap. `ImageCodecService` reports
/// `ImageCodecAvailability.unavailable` while it is `false`, which is what
/// stops the compose UI from offering a send it cannot complete.
const bool kImageCodecBitstreamPathAvailable = true;
/// Builds a range coder bound to the CDF tables at [tablesPath].
///
/// Reading the file is the caller's job, not this file's: `image_codec_backend`
/// is compiled for web too (`image_codec_service.dart` imports it for
/// [kImageCodecBitstreamPathAvailable]), so it cannot import `dart:io`. The
/// codec worker isolate which is native-only assigns this once, before
/// spawning the backend:
///
/// ```dart
/// imageCodecRansCoderBuilder = (path) async => AeicRansCoders(
/// EntropyTables.parse(await File(path).readAsBytes()),
/// );
/// ```
///
/// Left null, [OnnxImageCodecBackend.supportsBitstreamCodec] is false and
/// [OnnxImageCodecBackend.encode]/[OnnxImageCodecBackend.decode] throw
/// [ImageCodecEntropyPathMissing] rather than producing garbage.
typedef ImageCodecRansCoderBuilder =
Future<AeicRansCoderFactory> Function(String tablesPath);
ImageCodecRansCoderBuilder? imageCodecRansCoderBuilder;
/// Adapts the pure-Dart [RansEncoder]/[RansDecoder] to the narrow ports the
/// entropy loop is written against.
///
/// The indirection is not ceremony: it keeps `image_codec_entropy.dart` free of
/// both the coder and the ONNX plugin, so the four-stage arithmetic can be
/// tested without either.
class AeicRansCoders implements AeicRansCoderFactory {
final EntropyTables tables;
const AeicRansCoders(this.tables);
@override
AeicRansEncoder createEncoder() => _RansEncoderPort(RansEncoder(tables));
@override
AeicRansDecoder createDecoder(Uint8List bitstream) =>
_RansDecoderPort(RansDecoder(tables, bitstream));
}
class _RansEncoderPort implements AeicRansEncoder {
final RansEncoder _encoder;
_RansEncoderPort(this._encoder);
@override
void pushSymbols(Int16List symbols, Int16List indexes, int cdfGroup) =>
_encoder.encodeWithIndexes(symbols, indexes, cdfGroup);
@override
Uint8List finish() => _encoder.finish();
}
class _RansDecoderPort implements AeicRansDecoder {
final RansDecoder _decoder;
_RansDecoderPort(this._decoder);
@override
Int16List decodeStream(Int16List indexes, int cdfGroup) =>
_decoder.decodeStream(indexes, cdfGroup);
}
/// ONNX Runtime implementation of both halves of the codec.
///
/// Platform-channel based, not FFI. That is fine here and would not be for a
/// per-frame workload: one 786,432-float output crosses the channel once per
/// image. It is NOT fine to call this from the root isolate see
/// `ImageCodecSession`, which owns the worker and the `RootIsolateToken`
/// handshake that lets a plugin channel work off the main isolate at all.
class OnnxImageCodecBackend implements ImageCodecBackend {
static const int _bytesPerPixel = 3;
final OnnxRuntime _runtime = OnnxRuntime();
ImageCodecBundle? _bundle;
OrtSession? _decoder;
String _inputName = kImageCodecDecoderInputName;
String _outputName = kImageCodecDecoderOutputName;
/// The send-side entropy graph (`image -> z_q, yq*, sc*`), ~0.35 GiB peak.
OrtSession? _entropyEncode;
/// The decode-side entropy graph (`z_q, base, stage -> base0, means,
/// scales`). Same weights, different export; see [ImageCodecAssetRole].
OrtSession? _entropyDecode;
AeicRansCoderFactory? _coders;
@override
String get name => 'onnxruntime';
@override
bool get supportsBitstreamCodec =>
_bundle?.isComplete == true && imageCodecRansCoderBuilder != null;
@override
Future<void> load(Object bundle) async {
final resolved = switch (bundle) {
ImageCodecBundle b => b,
String path => ImageCodecBundle(decoderGraphPath: path),
_ => throw ArgumentError.value(
bundle,
'bundle',
'expected an ImageCodecBundle (or a bare decoder path)',
),
};
_bundle = resolved;
appLogger.info(
'ONNX codec bundle recorded: entropy=${resolved.entropyGraphPath != null} '
'tables=${resolved.tablesPath != null} rate=${resolved.ratePoint.name}',
tag: 'ImageCodec',
);
}
ImageCodecBundle _requireBundle() {
final bundle = _bundle;
if (bundle == null) {
throw StateError('ONNX codec backend has not been loaded.');
}
return bundle;
}
/// Creates the synthesis session if it is not already up.
Future<OrtSession> _ensureDecoder() async {
final existing = _decoder;
if (existing != null) {
return existing;
}
// No `providers:` is passed on purpose. Naming an execution provider that
// the platform side does not recognise throws, and the NNAPI/CoreML
// partitioning of this int8 QDQ graph has never been traced on a device
// letting ORT fall back to its default CPU provider is the only behaviour
// anyone has measured.
//
// The path must be the `.onnx` graph, with its `.onnx.data`
// external-weights sibling present in the same directory under exactly the
// filename the graph records. See `ImageCodecModelSpec`.
final session = await _runtime.createSession(_requireBundle().decoderGraphPath);
_decoder = session;
_inputName = _pick(session.inputNames, kImageCodecDecoderInputName, 'input');
_outputName = _pick(
session.outputNames,
kImageCodecDecoderOutputName,
'output',
);
appLogger.info(
'ONNX decoder session ready: in=$_inputName out=$_outputName',
tag: 'ImageCodec',
);
return session;
}
/// Creates the entropy session this direction needs, plus the range coder.
///
/// ONE DIRECTION AT A TIME, deliberately. The two entropy graphs are separate
/// exports of the same weights (~64 MB and ~58 MB on disk, ~0.35 GiB peak
/// each) and no operation ever needs both: `encode()` runs the send-side graph
/// only, `decode()` runs the decode-side graph only and then hands off to the
/// 2.16 GiB synthesis session. Creating both would double the entropy-side
/// cost for nothing.
Future<AeicEntropyCodec> _ensureEntropy(
int resolution, {
required bool forDecode,
}) async {
final bundle = _requireBundle();
final tablesPath = bundle.tablesPath;
if (tablesPath == null) {
throw const ImageCodecBundleIncomplete();
}
final builder = imageCodecRansCoderBuilder;
if (builder == null) {
throw const ImageCodecEntropyPathMissing(
'the pure-Dart rANS coder is not wired into this build; set '
'imageCodecRansCoderBuilder before loading the codec',
);
}
OrtSession? encodeSession;
OrtSession? decodeSession;
if (forDecode) {
final path = bundle.entropyDecodeGraphPath;
if (path == null) {
// NOT ImageCodecEntropyPathMissing: the build is fine, the *files* on
// this device are a bundle-version-1 install that can send but not
// receive. The remedy is a re-download, so say so. Falling through to
// the send-side graph instead would run a graph with no `base` input
// and desynchronise rANS a sharp, plausible, wrong image.
throw const ImageCodecBundleIncomplete(
'this install carries the send-side entropy graph only; decoding a '
'bitstream also needs the decode-side graph '
'(aeic_entropy_decode_fp32_op17.onnx). Re-download the image codec '
'model.',
);
}
decodeSession = _entropyDecode ??= await _runtime.createSession(path);
} else {
final path = bundle.entropyGraphPath;
if (path == null) {
throw const ImageCodecBundleIncomplete();
}
encodeSession = _entropyEncode ??= await _runtime.createSession(path);
}
final coders = _coders ??= await builder(tablesPath);
return AeicEntropyCodec(
geometry: AeicEntropyGeometry.forResolution(resolution),
network: OnnxAeicEntropyNetwork(
encodeSession: encodeSession,
decodeSession: decodeSession,
),
coders: coders,
);
}
/// Prefers the documented tensor name, tolerates a re-export that renamed a
/// sole input/output, and refuses to guess between several.
static String _pick(List<String> names, String preferred, String role) {
if (names.contains(preferred)) {
return preferred;
}
if (names.length == 1) {
return names.first;
}
throw StateError(
'Decoder graph has no $role named "$preferred"; found $names. '
'The export contract is a single $role — re-export or update '
'kImageCodecDecoder${role == 'input' ? 'Input' : 'Output'}Name.',
);
}
@override
Future<Uint8List> decodeLatentToRgb({
required Float32List yHat,
void Function(double progress)? onProgress,
bool Function()? shouldCancel,
}) async {
if (yHat.length != kImageCodecLatentElements) {
throw ArgumentError.value(
yHat.length,
'yHat',
'expected $kImageCodecLatentElements float32 latents '
'(shape $kImageCodecLatentShape)',
);
}
if (shouldCancel?.call() == true) {
throw const ImageCodecCancelled();
}
onProgress?.call(0.02);
final session = await _ensureDecoder();
OrtValue? input;
final outputs = <OrtValue>[];
try {
input = await OrtValue.fromList(yHat, kImageCodecLatentShape);
onProgress?.call(0.05);
if (shouldCancel?.call() == true) {
throw const ImageCodecCancelled();
}
// The single long blocking stage. `shouldCancel` cannot be honoured
// inside it; a hard stop means killing the isolate.
final result = await session.run(<String, OrtValue>{_inputName: input});
outputs.addAll(result.values);
onProgress?.call(0.85);
final output = result[_outputName] ?? result.values.first;
final flat = await output.asFlattenedList();
onProgress?.call(0.95);
final rgb = _chwFloatsToRgb(flat, output.shape);
onProgress?.call(1.0);
return rgb;
} finally {
await input?.dispose();
for (final value in outputs) {
await value.dispose();
}
}
}
/// `[1, 3, H, W]` floats in [-1, 1] -> packed 8-bit RGB, `H * W * 3` bytes.
static Uint8List _chwFloatsToRgb(List<dynamic> flat, List<int> shape) {
final expectedSide = kImageCodecSquareSize;
final pixels = expectedSide * expectedSide;
final expected = pixels * _bytesPerPixel;
if (flat.length != expected) {
throw StateError(
'Decoder returned ${flat.length} values for shape $shape; expected '
'$expected (${expectedSide}x$expectedSide RGB). The export is static '
'512x512 — a different shape means the wrong model file.',
);
}
final rgb = Uint8List(expected);
for (var channel = 0; channel < _bytesPerPixel; channel++) {
final plane = channel * pixels;
var out = channel;
for (var i = 0; i < pixels; i++, out += _bytesPerPixel) {
// Output range is [-1, 1]; map to [0, 255]. Values outside the range do
// occur (the last conv is unbounded) and must be clamped, not wrapped.
final scaled = ((flat[plane + i] as num).toDouble() + 1.0) * 127.5;
rgb[out] = scaled <= 0
? 0
: scaled >= 255
? 255
: scaled.round();
}
}
return rgb;
}
@override
Future<Uint8List> encode({
required Uint8List rgbBytes,
required AeicRatePoint ratePoint,
required int resolution,
void Function(double progress)? onProgress,
bool Function()? shouldCancel,
}) async {
_checkRatePoint(ratePoint);
final codec = await _ensureEntropy(resolution, forDecode: false);
try {
// The entropy session stays up on purpose: a second photo is free.
return await codec.encode(
rgbBytes,
onProgress: onProgress,
shouldCancel: shouldCancel,
);
} on AeicEntropyCancelled {
throw const ImageCodecCancelled();
}
}
@override
Future<Uint8List> decode({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
void Function(double progress)? onProgress,
bool Function()? shouldCancel,
}) async {
_checkRatePoint(ratePoint);
final codec = await _ensureEntropy(resolution, forDecode: true);
final Float32List yHat;
try {
yHat = await codec.decodeToLatent(
bitstream,
onProgress: (value) => onProgress?.call(value * 0.5),
shouldCancel: shouldCancel,
);
} on AeicEntropyCancelled {
throw const ImageCodecCancelled();
}
// MANDATORY, not an optimisation: never hold the fp32 entropy graph and the
// 2.16 GiB synthesis session at the same time.
await releaseEntropySession();
return decodeLatentToRgb(
yHat: yHat,
onProgress: (value) => onProgress?.call(0.5 + value * 0.5),
shouldCancel: shouldCancel,
);
}
/// The graphs and the CDF tables belong to one checkpoint. A bitstream that
/// claims a different rate point cannot be decoded with these tables, and
/// would decode into a sharp, plausible, wrong image if it were tried.
void _checkRatePoint(AeicRatePoint ratePoint) {
final expected = _requireBundle().ratePoint;
if (ratePoint != expected) {
throw ImageCodecUnimplemented(
'this bundle is ${expected.name}; the bitstream claims '
'${ratePoint.name}. Rate points are not interchangeable: the CDF '
'tables and the entropy graph belong to one checkpoint.',
);
}
}
@override
Future<void> releaseDecoderSession() async {
final session = _decoder;
_decoder = null;
await _close(session, 'decoder');
}
/// Drops **both** entropy graphs. Callers ask for "the entropy half"; which
/// direction happens to be resident is an implementation detail, and a decode
/// that is about to create the 2.16 GiB synthesis session must not leave the
/// send-side graph behind just because it never used it.
@override
Future<void> releaseEntropySession() async {
final encodeSession = _entropyEncode;
final decodeSession = _entropyDecode;
_entropyEncode = null;
_entropyDecode = null;
await _close(encodeSession, 'entropy(encode)');
await _close(decodeSession, 'entropy(decode)');
}
@override
Future<void> dispose() async {
await releaseDecoderSession();
await releaseEntropySession();
_coders = null;
}
static Future<void> _close(OrtSession? session, String which) async {
if (session == null) {
return;
}
try {
await session.close();
} catch (error) {
// Never let teardown throw: it is called from memory-pressure and from
// isolate shutdown, where there is nobody left to handle it.
appLogger.warn(
'ONNX $which session close failed: $error',
tag: 'ImageCodec',
);
}
}
}
/// [AeicEntropyNetwork] backed by the fp32 entropy ONNX graphs.
///
/// ## TWO GRAPHS, NOT ONE. This is not a packaging accident.
///
/// The send-side export (`aeic_entropy_side_fp32_op17.onnx`, 64 MB) has one
/// input `image [1,3,512,512]` and emits `z_q`, `yq0..3`, `sc0..3` in a single
/// run. That shape is only valid for an encoder, which already knows `y`.
/// Decoding is inherently SEQUENTIAL: the symbols of stage `i` must be decoded
/// before stage `i+1`'s context can be computed, so a receiver must be able to
/// call the network five times per image, interleaved with rANS.
///
/// The decode-side export (`aeic_entropy_decode_fp32_op17.onnx`, 58 MB) is that
/// callable form the same sub-networks behind an ONNX `If` on a `stage`
/// selector. `flutter_onnxruntime`'s `session.run()` fetches ALL graph outputs
/// (there is no output-subset API), so the `If` is what stops each call from
/// evaluating `g_c` four times.
///
/// ### Decode-side contract (measured, not assumed)
///
/// ```
/// inputs : z_q float32 [1,128,4,4] base float32 [1,256,16,16]
/// stage int32 [1]
/// outputs: base0 float32 [1,256,16,16] means float32 [1,256,16,16]
/// scales float32 [1,256,16,16]
/// ```
///
/// * ALL THREE INPUTS ARE REQUIRED ON EVERY RUN. ORT rejects a feed that omits
/// one ("Required inputs (['base']) are missing from input feed"), so the
/// branch that does not read a tensor is still handed a zero filler. That is
/// what [_zeroBase] and [_zeroZq] are for; they are not defensive padding.
/// * `stage < 0` takes the HYPER branch: `base0 = h_s(z_q + z_offset)`, with
/// `z_offset` (the entropy-bottleneck medians) baked into the graph as a
/// constant. The input is therefore raw `z_q`, NOT `z_hat` adding the
/// offset in Dart would add it twice.
/// * `stage 0..3` takes the CONTEXT branch:
/// `adapter_out[stage](g_c(adapter_in[stage](base)))`, chunked into
/// `means`/`scales` and **already multiplied by that stage's mask**.
/// `AeicMaskSet.applyMask` is a select with the identical mask, so applying it
/// again in `image_codec_entropy.dart` is exactly idempotent that call site
/// needs no change, and removing it would couple the arithmetic to this
/// export.
/// * The branch not taken emits a zero tensor of the same shape, so a caller
/// must read only the output its `stage` selects.
/// * Latency on an M4 Max, 1 thread, CPU EP, ORT 1.28.0: 6.11 ms for the hyper
/// branch and 3.70 ms per stage, ~21 ms per image.
///
/// Geometry is fixed 512x512 -> `y` 16x16, `z` 4x4. No dynamic axes.
///
/// A network constructed with only [encodeSession] reports
/// [supportsDecodeSide] false, and `AeicEntropyCodec.decodeToLatent` refuses
/// with [AeicEntropyUnavailable] rather than feeding a send-side graph inputs it
/// does not have.
class OnnxAeicEntropyNetwork implements AeicEntropyNetwork {
static const String kImageInput = 'image';
static const String kZqInput = 'z_q';
static const String kBaseInput = 'base';
static const String kStageInput = 'stage';
static const String kBase0Output = 'base0';
static const String kMeansOutput = 'means';
static const String kScalesOutput = 'scales';
/// The `stage` value that selects the hyper-synthesis branch. Any negative
/// value works; -1 is the documented one.
static const int kHyperStage = -1;
static const List<int> kZqShape = <int>[1, 128, 4, 4];
static const List<int> kBaseShape = <int>[1, 256, 16, 16];
static const int kZqElements = 128 * 4 * 4;
static const int kBaseElements = 256 * 16 * 16;
/// Session over the send-side graph, or null on a decode-only network.
final OrtSession? encodeSession;
/// Session over the decode-side graph, or null on an encode-only network.
final OrtSession? decodeSession;
const OnnxAeicEntropyNetwork({this.encodeSession, this.decodeSession});
@override
bool get supportsDecodeSide {
final session = decodeSession;
if (session == null) {
return false;
}
final inputs = session.inputNames;
final outputs = session.outputNames;
return inputs.contains(kZqInput) &&
inputs.contains(kBaseInput) &&
inputs.contains(kStageInput) &&
outputs.contains(kBase0Output) &&
outputs.contains(kMeansOutput) &&
outputs.contains(kScalesOutput);
}
@override
Future<AeicEncodeSideTensors> runEncodeSide(Float32List imageChw) async {
final session = encodeSession;
if (session == null) {
throw const AeicEntropyUnavailable(
'this network has no send-side entropy session, so it cannot encode',
);
}
if (!session.inputNames.contains(kImageInput)) {
throw const AeicEntropyUnavailable(
'the entropy graph has no "image" input, so it cannot encode',
);
}
final side = imageChw.length ~/ 3;
final resolution = _isqrt(side);
final inputs = <String, OrtValue>{
kImageInput: await OrtValue.fromList(imageChw, <int>[
1,
3,
resolution,
resolution,
]),
};
final result = await _run(session, inputs);
try {
return AeicEncodeSideTensors(
zQ: await _floats(result, 'z_q'),
yQ: <Float32List>[
for (var i = 0; i < 4; i++) await _floats(result, 'yq$i'),
],
scales: <Float32List>[
for (var i = 0; i < 4; i++) await _floats(result, 'sc$i'),
],
);
} finally {
await _disposeAll(result);
}
}
@override
Future<Float32List> runHyperSynthesis(Float32List zQ) async {
final session = _requireDecodeSide();
if (zQ.length != kZqElements) {
throw ArgumentError.value(
zQ.length,
'zQ',
'expected $kZqElements float32 values (shape $kZqShape)',
);
}
// `base` is not read by the hyper branch, but ORT requires every declared
// input to be fed. Zeros, not a stale tensor: a stale one would be silently
// wrong the day the export starts reading it.
final result = await _run(session, <String, OrtValue>{
kZqInput: await OrtValue.fromList(zQ, kZqShape),
kBaseInput: await OrtValue.fromList(
Float32List(kBaseElements),
kBaseShape,
),
kStageInput: await OrtValue.fromList(Int32List.fromList(<int>[
kHyperStage,
]), <int>[1]),
});
try {
return await _floats(result, kBase0Output);
} finally {
await _disposeAll(result);
}
}
@override
Future<AeicStageParams> runStage(int stage, Float32List base) async {
final session = _requireDecodeSide();
if (stage < 0 || stage > 3) {
// The graph does not validate `stage`: anything >= 4 silently falls into
// the stage-3 branch and anything < 0 runs hyper synthesis, either of
// which desynchronises rANS without an error. Catch it here instead.
throw ArgumentError.value(stage, 'stage', 'must be 0..3');
}
if (base.length != kBaseElements) {
throw ArgumentError.value(
base.length,
'base',
'expected $kBaseElements float32 values (shape $kBaseShape)',
);
}
// `z_q` is not read by the context branch; same reasoning as above.
final result = await _run(session, <String, OrtValue>{
kZqInput: await OrtValue.fromList(Float32List(kZqElements), kZqShape),
kBaseInput: await OrtValue.fromList(base, kBaseShape),
kStageInput: await OrtValue.fromList(Int32List.fromList(<int>[
stage,
]), <int>[1]),
});
try {
// Already masked by the graph. `AeicEntropyCodec` masks again, which is
// idempotent see the class doc.
return AeicStageParams(
meansSupp: await _floats(result, kMeansOutput),
scalesSupp: await _floats(result, kScalesOutput),
);
} finally {
await _disposeAll(result);
}
}
OrtSession _requireDecodeSide() {
final session = decodeSession;
if (session == null) {
throw const AeicEntropyUnavailable(
'this network has no decode-side entropy session; decoding needs '
'aeic_entropy_decode_fp32_op17.onnx, which a bundle-version-1 install '
'does not carry',
);
}
if (!supportsDecodeSide) {
throw AeicEntropyUnavailable(
'the loaded decode-side graph does not match the contract (inputs '
'${session.inputNames}, outputs ${session.outputNames}); decoding '
'needs inputs [$kZqInput, $kBaseInput, $kStageInput] and outputs '
'[$kBase0Output, $kMeansOutput, $kScalesOutput]',
);
}
return session;
}
Future<Map<String, OrtValue>> _run(
OrtSession session,
Map<String, OrtValue> inputs,
) async {
try {
return await session.run(inputs);
} finally {
for (final value in inputs.values) {
await value.dispose();
}
}
}
static Future<Float32List> _floats(
Map<String, OrtValue> result,
String name,
) async {
final value = result[name];
if (value == null) {
throw AeicEntropyUnavailable(
'the entropy graph has no output named "$name"; found '
'${result.keys.toList()}',
);
}
final flat = await value.asFlattenedList();
final out = Float32List(flat.length);
for (var i = 0; i < flat.length; i++) {
out[i] = (flat[i] as num).toDouble();
}
return out;
}
static Future<void> _disposeAll(Map<String, OrtValue> values) async {
for (final value in values.values) {
await value.dispose();
}
}
static int _isqrt(int value) {
final root = math.sqrt(value).round();
if (root * root != value) {
throw ArgumentError.value(value, 'value', 'not a square image');
}
return root;
}
}
/// Thrown by a backend that noticed [ImageCodecBackend] `shouldCancel`.
class ImageCodecCancelled implements Exception {
const ImageCodecCancelled();
@override
String toString() => 'Image processing stopped.';
}
/// Factory used by the codec worker isolate.
///
/// Returns `null` on web, where there is no local model file and no isolate.
/// Everywhere else it returns the real [OnnxImageCodecBackend]; the session
/// turns a `null` here into an [ImageCodecUnimplemented], which the service
/// surfaces as `lastError` and maps to `ImageCodecAvailability.unavailable`.
ImageCodecBackend? createImageCodecBackend() {
if (kIsWeb) {
return null;
}
return OnnxImageCodecBackend();
}
+675
View File
@@ -0,0 +1,675 @@
/// ===========================================================================
/// THE ENTROPY LAYER everything between the ONNX tensors and the rANS coder.
/// ===========================================================================
///
/// AEIC's four-stage masked context model, ported from
/// `aic/aeic/src/codec/codec_practical.py` (`PixelCodec.compress` /
/// `.decompress`). This file owns the parts that are *not* neural and *not*
/// range coding:
///
/// * the four checkerboard masks (`get_mask_four_parts`),
/// * `sequeeze` / `unsequeeze_with_mask` (the 4:1 channel fold),
/// * `torch.round`'s half-to-**even** tie rule,
/// * `my_build_indexes` (scale -> CDF row selector),
/// * the z index array (`_build_indexes`: the channel arange),
/// * the fixed call order z, y0, y1, y2, y3.
///
/// It is deliberately pure Dart: no ONNX, no dart:io, no Flutter. The neural
/// half arrives through [AeicEntropyNetwork] and the range coder through
/// [AeicRansCoderFactory], both injected. That is what makes the arithmetic
/// unit-testable without a 67 MB graph or a model download.
///
/// ## Why every detail here is load-bearing
///
/// The rANS decoder re-derives symbol probabilities by re-running the same
/// network and the same arithmetic. A single wrong symbol *position* an
/// off-by-one in a mask, the wrong channel-group permutation, `sequeeze`
/// folding in the wrong order does not raise: it desynchronises the coder and
/// produces a sharp, plausible, wrong image. Same for a rounding tie resolved
/// away from zero instead of to even. Both failure modes are silent.
///
/// The golden vectors in `test/services/image_codec_entropy_test.dart` were
/// generated by `aic/exp/export_entropy_layer_golden.py`, which runs the real
/// torch ops on a closed-form input both languages reproduce bit-for-bit.
library;
import 'dart:math' as math;
import 'dart:typed_data';
/// CDF group indices, fixed by `EntropyCoder.update()`'s `add_cdf` call order:
/// the entropy bottleneck (z) is registered first, the Gaussian conditional
/// (y) second. The group index is part of the bitstream format.
const int kAeicZCdfGroup = 0;
const int kAeicYCdfGroup = 1;
/// `my_build_indexes` constants (`codec_practical.py`, `SCALES_MIN/MAX/LEVELS`).
const int kAeicScalesLevels = 64;
const double kAeicLogScaleMin = -2.2072749131897207; // ln(0.11)
const double kAeicLogScaleStep = 0.12305479932808384; // (ln(256)-ln(0.11))/63
const double kAeicScaleThreshold = 0.08; // below this the symbol is skipped
const double kAeicScaleFloor = 1e-5; // torch.maximum floor applied first
/// Tensor shapes for one image, derived the way `compress()` derives them.
///
/// `y` is the image downsampled by 32; `z` is `y` downsampled by a further 4,
/// rounded **up** (`compress()` reflect-pads `y` to a multiple of 4 first).
class AeicEntropyGeometry {
final int resolution;
/// Latent channel count `M` (256 for AEIC-SE, which is what ships).
final int yChannels;
final int yHeight;
final int yWidth;
/// Hyper-latent channels: `M // 2`.
final int zChannels;
final int zHeight;
final int zWidth;
const AeicEntropyGeometry._({
required this.resolution,
required this.yChannels,
required this.yHeight,
required this.yWidth,
required this.zChannels,
required this.zHeight,
required this.zWidth,
});
factory AeicEntropyGeometry.forResolution(
int resolution, {
int yChannels = 256,
}) {
if (resolution <= 0 || resolution % 32 != 0) {
throw ArgumentError.value(
resolution,
'resolution',
'must be a positive multiple of 32 (g_a downsamples by 32)',
);
}
if (yChannels % 4 != 0) {
throw ArgumentError.value(
yChannels,
'yChannels',
'the four-part mask splits the channels into 4 equal groups',
);
}
final yh = resolution ~/ 32;
final zh = (yh + 3) ~/ 4;
return AeicEntropyGeometry._(
resolution: resolution,
yChannels: yChannels,
yHeight: yh,
yWidth: yh,
zChannels: yChannels ~/ 2,
zHeight: zh,
zWidth: zh,
);
}
/// Channels after `sequeeze` folds the four groups together.
int get squeezedChannels => yChannels ~/ 4;
int get yElements => yChannels * yHeight * yWidth;
/// Symbols coded per y stage (`[1, M/4, y_h, y_w]` flattened).
int get symbolsPerStage => squeezedChannels * yHeight * yWidth;
int get zElements => zChannels * zHeight * zWidth;
/// Total entries handed to the coder: z once, then four y stages.
int get totalEntries => zElements + 4 * symbolsPerStage;
List<int> get yShape => <int>[1, yChannels, yHeight, yWidth];
List<int> get zShape => <int>[1, zChannels, zHeight, zWidth];
List<int> get imageShape => <int>[1, 3, resolution, resolution];
}
/// The four masks of `get_mask_four_parts`, as arithmetic rather than tensors.
///
/// Reading the Python: each mask is four channel-groups of `M/4` channels
/// stacked, and each group carries one of the four 2x2 micro-patterns
///
/// micro 0 = (y%2, x%2) == (0, 0) micro 1 = (0, 1)
/// micro 2 = (1, 0) micro 3 = (1, 1)
///
/// so `micro k` is live exactly where `(y%2)*2 + (x%2) == k`. The stacking order
/// per stage is
///
/// mask_0 = [m0, m1, m2, m3] mask_1 = [m3, m2, m1, m0]
/// mask_2 = [m2, m3, m0, m1] mask_3 = [m1, m0, m3, m2]
///
/// which is exactly `micro = group XOR perm[stage]` with `perm = [0, 3, 2, 1]`.
/// Verified against the tensors themselves in the golden test, because getting
/// this permutation wrong is the single easiest way to silently reorder every
/// symbol in the stream.
class AeicMaskSet {
/// `perm[stage]`: XOR it with the channel group to get the micro-pattern.
static const List<int> stagePermutation = <int>[0, 3, 2, 1];
final AeicEntropyGeometry geometry;
const AeicMaskSet(this.geometry);
/// Micro-pattern index used by [channelGroup] in [stage].
static int microFor(int stage, int channelGroup) =>
channelGroup ^ stagePermutation[stage];
/// The single channel group that is live at `(y, x)` in [stage].
///
/// Inverse of [microFor]: the position selects the micro-pattern, and exactly
/// one group carries it.
static int liveGroupAt(int stage, int y, int x) =>
(((y & 1) << 1) | (x & 1)) ^ stagePermutation[stage];
bool isLive(int stage, int channel, int y, int x) {
final group = channel ~/ geometry.squeezedChannels;
return microFor(stage, group) == (((y & 1) << 1) | (x & 1));
}
/// Materialises `mask_i` as `[1, M, y_h, y_w]` float32 only needed when a
/// caller wants to hand the mask to something else (or check it in a test).
Float32List maskTensor(int stage) {
final out = Float32List(geometry.yElements);
final h = geometry.yHeight;
final w = geometry.yWidth;
for (var c = 0; c < geometry.yChannels; c++) {
final base = c * h * w;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
if (isLive(stage, c, y, x)) {
out[base + y * w + x] = 1.0;
}
}
}
}
return out;
}
/// `sequeeze`: sum the four channel chunks, `(g0 + g1) + (g2 + g3)`.
///
/// The association matters in principle (float addition is not associative)
/// so it is reproduced exactly, even though in practice three of the four
/// terms are a hard zero for a masked tensor.
Float32List squeeze(Float32List full) {
if (full.length != geometry.yElements) {
throw ArgumentError.value(
full.length,
'full',
'expected ${geometry.yElements} elements (${geometry.yShape})',
);
}
final plane = geometry.yHeight * geometry.yWidth;
final cq = geometry.squeezedChannels;
final out = Float32List(cq * plane);
for (var c = 0; c < cq; c++) {
final o = c * plane;
final g0 = c * plane;
final g1 = (c + cq) * plane;
final g2 = (c + 2 * cq) * plane;
final g3 = (c + 3 * cq) * plane;
for (var i = 0; i < plane; i++) {
// Each partial sum is forced back through float32, matching torch's
// `(a + b) + (c + d)` on float32 tensors.
final left = f32(full[g0 + i] + full[g1 + i]);
final right = f32(full[g2 + i] + full[g3 + i]);
out[o + i] = left + right;
}
}
return out;
}
/// `unsequeeze_with_mask`: broadcast a squeezed tensor back to `[1, M, h, w]`,
/// keeping each element only in the channel group whose mask is live there.
Float32List unsqueeze(Float32List squeezed, int stage) {
final plane = geometry.yHeight * geometry.yWidth;
final cq = geometry.squeezedChannels;
if (squeezed.length != cq * plane) {
throw ArgumentError.value(
squeezed.length,
'squeezed',
'expected ${cq * plane} elements',
);
}
final out = Float32List(geometry.yElements);
final w = geometry.yWidth;
for (var c = 0; c < cq; c++) {
for (var y = 0; y < geometry.yHeight; y++) {
for (var x = 0; x < w; x++) {
final group = liveGroupAt(stage, y, x);
out[(group * cq + c) * plane + y * w + x] = squeezed[c * plane +
y * w +
x];
}
}
}
return out;
}
/// `t * mask_i`, elementwise, into a fresh tensor.
Float32List applyMask(Float32List full, int stage) {
final out = Float32List(full.length);
final plane = geometry.yHeight * geometry.yWidth;
final cq = geometry.squeezedChannels;
final w = geometry.yWidth;
for (var c = 0; c < geometry.yChannels; c++) {
final group = c ~/ cq;
for (var y = 0; y < geometry.yHeight; y++) {
for (var x = 0; x < w; x++) {
if (microFor(stage, group) == (((y & 1) << 1) | (x & 1))) {
final i = c * plane + y * w + x;
out[i] = full[i];
}
}
}
}
return out;
}
/// `base * (1 - mask_i) + stageLatent`, the context update between stages.
///
/// `stageLatent` must already be masked (it is the output of [unsqueeze]), so
/// this is a select, not an add which is what keeps it exact in float32.
Float32List mergeContext(Float32List base, Float32List stageLatent, int stage) {
final out = Float32List.fromList(base);
final plane = geometry.yHeight * geometry.yWidth;
final cq = geometry.squeezedChannels;
final w = geometry.yWidth;
for (var c = 0; c < geometry.yChannels; c++) {
final group = c ~/ cq;
for (var y = 0; y < geometry.yHeight; y++) {
for (var x = 0; x < w; x++) {
if (microFor(stage, group) == (((y & 1) << 1) | (x & 1))) {
final i = c * plane + y * w + x;
out[i] = stageLatent[i];
}
}
}
}
return out;
}
}
/// Scratch cell used to force a float64 value through float32 rounding.
final Float32List _f32Cell = Float32List(1);
/// Rounds [value] to float32 exactly as a store into a float32 tensor would.
double f32(double value) {
_f32Cell[0] = value;
return _f32Cell[0];
}
/// `torch.round`: ties go to the **even** neighbour, not away from zero.
///
/// Dart's `double.round()` and `roundToDouble()` both round half away from
/// zero, so `(-0.5).round() == -1` where torch gives `0`. On the ft32 corpus
/// `y - means` lands on a tie often enough that this is not theoretical.
double roundHalfToEven(double value) {
if (!value.isFinite) {
return value;
}
final floor = value.floorToDouble();
final diff = value - floor;
if (diff > 0.5) {
return floor + 1.0;
}
if (diff < 0.5) {
return floor;
}
// Exactly halfway: pick the even neighbour.
return floor % 2.0 == 0.0 ? floor : floor + 1.0;
}
/// `my_build_indexes` scale -> CDF row selector, in float32, per stage.
///
/// ```
/// s = max(scale, 1e-5)
/// q = (ln(s) - log_scale_min) / log_scale_step # all float32
/// idx = (s < 0.08) ? -1 : trunc(clamp(q, 0, 63)) # trunc, not round
/// ```
///
/// Every intermediate is forced back to float32 because torch evaluates this on
/// a float32 tensor with float32 scalars, and the boundary margin measured in
/// `aic/results/bitexact_encoder.md` is only 1.12x the tightest number in the
/// whole system. Dart's `log` is still not guaranteed bit-identical to ORT's
/// float32 `Log`; see the note in `aic/results/rans_port_spec.md` §8, which
/// recommends the graph emit these indexes itself. [aeicBuildIndexes] is the
/// fallback, and both sides of the channel run this same code, so sender and
/// receiver agree with each other regardless.
Int16List aeicBuildIndexes(Float32List scales) {
final logMin = f32(kAeicLogScaleMin);
final logStep = f32(kAeicLogScaleStep);
final floor = f32(kAeicScaleFloor);
final threshold = f32(kAeicScaleThreshold);
final out = Int16List(scales.length);
for (var i = 0; i < scales.length; i++) {
final raw = scales[i];
final s = raw > floor ? raw : floor;
if (s < threshold) {
out[i] = -1;
continue;
}
var q = f32(f32(f32(math.log(s)) - logMin) / logStep);
if (q < 0.0) {
q = 0.0;
} else if (q > kAeicScalesLevels - 1) {
q = (kAeicScalesLevels - 1).toDouble();
}
out[i] = q.toInt(); // truncation toward zero, matching Tensor.int()
}
return out;
}
/// The z index array: `_build_indexes` broadcasts `arange(C)` over `H x W`, so
/// this is `H*W` copies of each channel index. Always >= 0.
Int16List aeicZIndexes(AeicEntropyGeometry geometry) {
final plane = geometry.zHeight * geometry.zWidth;
final out = Int16List(geometry.zElements);
var i = 0;
for (var c = 0; c < geometry.zChannels; c++) {
for (var p = 0; p < plane; p++) {
out[i++] = c;
}
}
return out;
}
/// float32 tensor of integers -> int16 symbols, asserting the cast is lossless.
///
/// `py_rans` force-casts through `py::array_t<int16_t>`; the golden vectors
/// store the post-cast values. Anything that does not survive the cast means
/// the entropy model has gone somewhere the format cannot represent, and
/// truncating quietly would desync the decoder.
Int16List aeicToSymbols(Float32List values) {
final out = Int16List(values.length);
for (var i = 0; i < values.length; i++) {
final v = values[i];
final n = v.toInt();
if (n != v || n < -32768 || n > 32767) {
throw StateError(
'entropy symbol $v at index $i is not a lossless int16; the entropy '
'model produced a value the bitstream format cannot carry',
);
}
out[i] = n;
}
return out;
}
/// RGB bytes -> the graph's `image` input, reproducing torchvision's
/// `ToTensor()` + `Normalize([0.5]*3, [0.5]*3)`: `x = (b/255 - 0.5) / 0.5`.
Float32List aeicRgbToChw(Uint8List rgb, int resolution) {
final pixels = resolution * resolution;
if (rgb.length != pixels * 3) {
throw ArgumentError.value(
rgb.length,
'rgb',
'expected ${pixels * 3} bytes for ${resolution}x$resolution RGB',
);
}
final out = Float32List(pixels * 3);
for (var c = 0; c < 3; c++) {
final plane = c * pixels;
for (var i = 0; i < pixels; i++) {
final v = f32(rgb[i * 3 + c] / 255.0);
out[plane + i] = f32(v - 0.5) * 2.0; // /0.5 is exact
}
}
return out;
}
/// Everything the send side needs out of one forward pass of the entropy graph.
///
/// Matches the outputs of `aic/onnx/aeic_entropy_side_fp32_op17.onnx`, which
/// runs `g_a`, `h_a`, `h_s`, `g_c` and all four adapters in one shot the
/// encoder never has to be incremental, only the decoder does.
class AeicEncodeSideTensors {
/// `round(z - z_offset)`, `[1, M/2, z_h, z_w]`, integral floats.
final Float32List zQ;
/// `round(y * mask_i - means_i)` per stage, `[1, M, y_h, y_w]`, integral.
final List<Float32List> yQ;
/// `scales_supp_i * mask_i` per stage, `[1, M, y_h, y_w]`.
final List<Float32List> scales;
const AeicEncodeSideTensors({
required this.zQ,
required this.yQ,
required this.scales,
});
}
/// One decode stage's entropy parameters, **before** masking.
///
/// `adapter_out[i](g_c(adapter_in[i](base)))` split in half; the caller applies
/// `mask_i`. Kept unmasked so the graph does not have to know the stage index
/// at export time.
class AeicStageParams {
final Float32List meansSupp;
final Float32List scalesSupp;
const AeicStageParams({required this.meansSupp, required this.scalesSupp});
}
/// The neural half of the entropy path, as this file needs it.
///
/// ## Graph contract
///
/// **Send side** (exists today, `aeic_entropy_side_fp32_op17.onnx`, 67 MB):
/// input `image [1,3,512,512]` -> outputs `z_q`, `yq0..yq3`, `sc0..sc3`.
///
/// **Receive side** (NOT exported yet see the report for task B2): the
/// decoder cannot use the send-side graph, because it must interleave network
/// evaluation with symbol decoding: stage `i`'s indexes are unknown until
/// stages `< i` have been decoded and fed back through `g_c`. It needs
///
/// * `z_q [1,128,4,4]` -> `base0 [1,256,16,16]` (`h_s` with `z_offset` baked
/// in, exactly as the send-side export bakes it), and
/// * `base [1,256,16,16]` -> `means{i}`, `scales{i}` for `i` in 0..3
/// (unmasked `adapter_out[i](g_c(adapter_in[i](base)))`, split in half).
///
/// Both can live in one graph with two inputs and nine outputs; ORT prunes to
/// the requested output set, so asking only for `base0` does not run the
/// adapters and asking only for `means2`/`scales2` does not run `h_s`.
abstract class AeicEntropyNetwork {
/// Whether [runHyperSynthesis] and [runStage] are available. False for a
/// graph that only carries the send-side path.
bool get supportsDecodeSide;
Future<AeicEncodeSideTensors> runEncodeSide(Float32List imageChw);
/// `h_s(z_q + z_offset)[:, :, :y_h, :y_w]`.
Future<Float32List> runHyperSynthesis(Float32List zQ);
/// `adapter_out[stage](g_c(adapter_in[stage](base)))`, unmasked.
Future<AeicStageParams> runStage(int stage, Float32List base);
}
/// The rANS encoder, as the entropy layer uses it.
///
/// Implemented by the pure-Dart coder (task B1). Deliberately narrow: five
/// [pushSymbols] calls in the fixed order z, y0, y1, y2, y3, then one [finish].
abstract class AeicRansEncoder {
/// Appends one `(symbols, indexes)` pair. `indexes[i] < 0` emits nothing.
void pushSymbols(Int16List symbols, Int16List indexes, int cdfGroup);
/// Flushes both sub-streams and returns the container bytes.
Uint8List finish();
}
/// The rANS decoder, as the entropy layer uses it.
///
/// **Must be incremental**: each call resumes the rANS state where the last one
/// stopped. An implementation that decodes the whole stream up front cannot
/// work here see the class docs on [AeicEntropyNetwork].
abstract class AeicRansDecoder {
Int16List decodeStream(Int16List indexes, int cdfGroup);
}
/// Supplies coders bound to a parsed CDF table set (task B1 owns both).
abstract class AeicRansCoderFactory {
AeicRansEncoder createEncoder();
AeicRansDecoder createDecoder(Uint8List bitstream);
}
/// Thrown when the entropy loop is asked to run without a piece it needs.
class AeicEntropyUnavailable implements Exception {
final String detail;
const AeicEntropyUnavailable(this.detail);
@override
String toString() => 'AEIC entropy path unavailable: $detail';
}
/// Raised by [ImageCodecBackend] callers that pass a cancel predicate.
typedef AeicCancelCheck = bool Function();
/// The four-stage masked encode and decode, orchestrating [AeicEntropyNetwork]
/// and [AeicRansCoderFactory].
class AeicEntropyCodec {
final AeicEntropyGeometry geometry;
final AeicMaskSet masks;
final AeicEntropyNetwork network;
final AeicRansCoderFactory coders;
AeicEntropyCodec({
required this.geometry,
required this.network,
required this.coders,
}) : masks = AeicMaskSet(geometry);
/// Packed 8-bit RGB -> rANS bitstream (payload only, no chunk headers).
///
/// One forward pass, then z, y0, y1, y2, y3 into the coder in that order.
Future<Uint8List> encode(
Uint8List rgbBytes, {
void Function(double progress)? onProgress,
AeicCancelCheck? shouldCancel,
}) async {
onProgress?.call(0.02);
final input = aeicRgbToChw(rgbBytes, geometry.resolution);
_checkCancel(shouldCancel);
final tensors = await network.runEncodeSide(input);
onProgress?.call(0.80);
_checkCancel(shouldCancel);
if (tensors.zQ.length != geometry.zElements) {
throw StateError(
'entropy graph returned ${tensors.zQ.length} z values, expected '
'${geometry.zElements} (${geometry.zShape})',
);
}
if (tensors.yQ.length != 4 || tensors.scales.length != 4) {
throw StateError(
'entropy graph must return four y_q and four scales tensors, got '
'${tensors.yQ.length} / ${tensors.scales.length}',
);
}
final encoder = coders.createEncoder();
encoder.pushSymbols(
aeicToSymbols(tensors.zQ),
aeicZIndexes(geometry),
kAeicZCdfGroup,
);
for (var stage = 0; stage < 4; stage++) {
final symbols = aeicToSymbols(masks.squeeze(tensors.yQ[stage]));
final indexes = aeicBuildIndexes(masks.squeeze(tensors.scales[stage]));
encoder.pushSymbols(symbols, indexes, kAeicYCdfGroup);
onProgress?.call(0.80 + 0.04 * (stage + 1));
_checkCancel(shouldCancel);
}
final stream = encoder.finish();
onProgress?.call(1.0);
return stream;
}
/// rANS bitstream -> `y_hat [1, M, y_h, y_w]`, ready for the synthesis graph.
///
/// The mirror of [encode], and necessarily incremental: each stage's indexes
/// come from scales that only exist once the previous stage's symbols have
/// been decoded and pushed back through the context model.
Future<Float32List> decodeToLatent(
Uint8List bitstream, {
void Function(double progress)? onProgress,
AeicCancelCheck? shouldCancel,
}) async {
if (!network.supportsDecodeSide) {
throw const AeicEntropyUnavailable(
'the installed entropy graph is send-side only: it maps image -> '
'symbols and has no z_q -> base0 / base -> means,scales entry points, '
'which decoding requires',
);
}
onProgress?.call(0.02);
final decoder = coders.createDecoder(bitstream);
final zSymbols = decoder.decodeStream(
aeicZIndexes(geometry),
kAeicZCdfGroup,
);
final zQ = Float32List(zSymbols.length);
for (var i = 0; i < zSymbols.length; i++) {
zQ[i] = zSymbols[i].toDouble();
}
_checkCancel(shouldCancel);
var base = await network.runHyperSynthesis(zQ);
if (base.length != geometry.yElements) {
throw StateError(
'hyper synthesis returned ${base.length} values, expected '
'${geometry.yElements} (${geometry.yShape})',
);
}
onProgress?.call(0.20);
Float32List? stageLatent;
for (var stage = 0; stage < 4; stage++) {
_checkCancel(shouldCancel);
final params = await network.runStage(stage, base);
final scales = masks.applyMask(params.scalesSupp, stage);
final means = masks.applyMask(params.meansSupp, stage);
final indexes = aeicBuildIndexes(masks.squeeze(scales));
final symbols = decoder.decodeStream(indexes, kAeicYCdfGroup);
final meansSqueezed = masks.squeeze(means);
if (symbols.length != meansSqueezed.length) {
throw StateError(
'stage $stage decoded ${symbols.length} symbols but the context '
'model produced ${meansSqueezed.length} means',
);
}
final latentSqueezed = Float32List(symbols.length);
for (var i = 0; i < symbols.length; i++) {
latentSqueezed[i] = symbols[i] + meansSqueezed[i];
}
stageLatent = masks.unsqueeze(latentSqueezed, stage);
if (stage < 3) {
base = masks.mergeContext(base, stageLatent, stage);
}
onProgress?.call(0.20 + 0.19 * (stage + 1));
}
// y_hat = base * (1 - mask_3) + y_hat_3
return masks.mergeContext(base, stageLatent!, 3);
}
static void _checkCancel(AeicCancelCheck? shouldCancel) {
if (shouldCancel?.call() == true) {
throw const AeicEntropyCancelled();
}
}
}
/// Cancellation signal raised out of the entropy loop at a stage boundary.
class AeicEntropyCancelled implements Exception {
const AeicEntropyCancelled();
@override
String toString() => 'Image processing stopped.';
}
+2
View File
@@ -0,0 +1,2 @@
export 'image_codec_file_store_stub.dart'
if (dart.library.io) 'image_codec_file_store_io.dart';
+247
View File
@@ -0,0 +1,247 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:convert/convert.dart' show AccumulatorSink;
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import '../models/image_codec_support.dart';
/// On-disk storage for image-codec weights.
///
/// Mirrors `translation_file_store_io.dart` 1:1. It is a separate class rather
/// than a reuse of `TranslationFileStore` only because the directory name and
/// the record type differ; see the report for the (small) change that would
/// make the translation store generic enough to share.
class ImageCodecFileStore {
static final RegExp _chunkFilePattern = RegExp(r'^\..+_chunk_\d+$');
Future<String> modelDirectoryPath() async {
final baseDir = await getApplicationDocumentsDirectory();
final dir = Directory('${baseDir.path}/image_codec_models');
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
return dir.path;
}
Future<List<ImageCodecModelRecord>> scanDownloadedModels() async {
final dir = Directory(await modelDirectoryPath());
if (!dir.existsSync()) {
return const [];
}
final models = <ImageCodecModelRecord>[];
for (final entity in dir.listSync().whereType<File>()) {
final name = entity.uri.pathSegments.last;
if (name.startsWith('.')) {
// Hidden `.<file>_chunk_<n>` files are the resume state of an
// interrupted download and MUST survive a restart reaping them here
// (as this used to) is what made an 872 MB transfer restart from zero
// whenever the app was reopened. Anything else hidden is junk.
if (!_chunkFilePattern.hasMatch(name)) {
await entity.delete();
}
continue;
}
final stat = entity.statSync();
models.add(
ImageCodecModelRecord(
id: name,
name: name,
sourceUrl: '',
localPath: entity.path,
downloadedAt: stat.modified,
fileSizeBytes: stat.size,
),
);
}
return models;
}
Future<void> deleteModel(ImageCodecModelRecord model) async {
await deleteFile(model.localPath);
await deletePartialDownloads(model.name);
}
/// Removes any resume state for [fileName].
///
/// Called after a successful download and when a model is removed. Without it
/// the chunk files, which [scanDownloadedModels] now deliberately preserves,
/// would never be collected.
Future<void> deletePartialDownloads(String fileName) async {
if (fileName.isEmpty) return;
final dir = Directory(await modelDirectoryPath());
if (!dir.existsSync()) return;
// `.<fileName>[.<totalSize>]_chunk_<n>`. The optional size segment is what
// `ImageCodecService` appends so resume can never splice offsets computed
// for one upstream length onto a file of another. Anchored on both ends so
// sweeping `model.onnx` cannot take `model.onnx.data`'s chunks with it.
final pattern = RegExp(
'^\\.${RegExp.escape(fileName)}(\\.\\d+)?_chunk_\\d+\$',
);
for (final entity in dir.listSync().whereType<File>()) {
if (pattern.hasMatch(entity.uri.pathSegments.last)) {
await entity.delete();
}
}
}
Future<void> deleteFile(String path) async {
final file = File(path);
if (file.existsSync()) {
await file.delete();
}
}
Future<DownloadedCodecFile> writeModelBytes({
required String fileName,
required Stream<List<int>> chunks,
}) async {
final directoryPath = await modelDirectoryPath();
final file = File('$directoryPath/$fileName');
final sink = file.openWrite();
var fileSizeBytes = 0;
var completed = false;
try {
await for (final chunk in chunks) {
sink.add(chunk);
fileSizeBytes += chunk.length;
}
completed = true;
} finally {
await sink.close();
if (!completed && file.existsSync()) {
await file.delete();
}
}
return DownloadedCodecFile(
localPath: file.path,
fileSizeBytes: fileSizeBytes,
);
}
Future<String> chunkFilePath(String fileName, int index) async {
final dir = await modelDirectoryPath();
return '$dir/.${fileName}_chunk_$index';
}
Future<String> modelFilePath(String fileName) async {
final dir = await modelDirectoryPath();
return '$dir/$fileName';
}
/// Size of [path] in bytes, or 0 when it does not exist.
///
/// This is the whole basis of resume: a chunk file's length *is* its progress
/// marker, so no separate state file can go stale or disagree with the bytes.
Future<int> fileSize(String path) async {
final file = File(path);
if (!file.existsSync()) {
return 0;
}
return file.length();
}
/// Appends [chunks] to [path], creating it if absent.
///
/// Unlike [writeModelBytes] this does NOT delete the file when the stream
/// fails part-way: the partial bytes are exactly what the next attempt
/// resumes from. Returns the file's total size afterwards.
Future<int> appendBytes({
required String path,
required Stream<List<int>> chunks,
}) async {
final file = File(path);
await file.parent.create(recursive: true);
final sink = file.openWrite(mode: FileMode.append);
try {
await for (final chunk in chunks) {
sink.add(chunk);
}
} finally {
// flush() inside close(); the bytes written before a failure survive on
// purpose, so the next Range request can pick up from file.length().
await sink.close();
}
return file.length();
}
/// Streaming SHA-256 of [path] as lowercase hex.
///
/// Streamed, not `readAsBytes`: the data sibling is 869 MiB and reading it into
/// a Uint8List to hash it would defeat the point of downloading it to disk.
Future<String> sha256OfFile(String path) async {
final accumulator = AccumulatorSink<Digest>();
final converter = sha256.startChunkedConversion(accumulator);
try {
await for (final chunk in File(path).openRead()) {
converter.add(chunk);
}
} finally {
converter.close();
}
return accumulator.events.single.toString();
}
/// Reads an arbitrary file (a picked photo, not a model) as bytes.
///
/// Lives here so `ImageCodecService` never has to import `dart:io` and can
/// therefore still be constructed on web.
Future<Uint8List> readFileBytes(String path) => File(path).readAsBytes();
Future<DownloadedCodecFile> combineChunks({
required String fileName,
required List<String> chunkPaths,
}) async {
final dir = await modelDirectoryPath();
final finalPath = '$dir/$fileName';
final sink = File(finalPath).openWrite();
var totalSize = 0;
var completed = false;
try {
for (final chunkPath in chunkPaths) {
final chunkFile = File(chunkPath);
await sink.addStream(chunkFile.openRead());
totalSize += await chunkFile.length();
}
completed = true;
} finally {
await sink.close();
if (completed) {
for (final chunkPath in chunkPaths) {
final file = File(chunkPath);
if (file.existsSync()) {
await file.delete();
}
}
} else {
// Keep the chunk files: they are the resume state. Only the half-written
// merge target is thrown away. (The previous version deleted the chunks
// unconditionally, which is why an interrupted 872 MB download restarted
// from zero.)
final finalFile = File(finalPath);
if (finalFile.existsSync()) {
await finalFile.delete();
}
}
}
return DownloadedCodecFile(localPath: finalPath, fileSizeBytes: totalSize);
}
/// Free bytes are not queryable without a platform channel, so callers that
/// need a pre-flight space check must supply their own. Kept here as the
/// documented seam rather than a silent omission: an 833 MB download that
/// dies at 90% full is the most likely field failure for this feature.
// TODO(disk): add a free-space pre-flight (needs a platform channel or the
// `disk_space_plus` package) before enabling the download button.
}
class DownloadedCodecFile {
final String localPath;
final int fileSizeBytes;
const DownloadedCodecFile({
required this.localPath,
required this.fileSizeBytes,
});
}
@@ -0,0 +1,68 @@
import 'dart:typed_data';
import '../models/image_codec_support.dart';
class ImageCodecFileStore {
Future<String> modelDirectoryPath() async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
Future<List<ImageCodecModelRecord>> scanDownloadedModels() async {
return const [];
}
Future<void> deleteModel(ImageCodecModelRecord model) async {}
Future<void> deleteFile(String path) async {}
Future<DownloadedCodecFile> writeModelBytes({
required String fileName,
required Stream<List<int>> chunks,
}) async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
Future<String> chunkFilePath(String fileName, int index) async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
Future<String> modelFilePath(String fileName) async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
Future<void> deletePartialDownloads(String fileName) async {}
Future<int> fileSize(String path) async => 0;
Future<int> appendBytes({
required String path,
required Stream<List<int>> chunks,
}) async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
Future<String> sha256OfFile(String path) async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
Future<Uint8List> readFileBytes(String path) async {
throw UnsupportedError('Local file reads are not supported on web.');
}
Future<DownloadedCodecFile> combineChunks({
required String fileName,
required List<String> chunkPaths,
}) async {
throw UnsupportedError('Local codec model storage is not supported on web.');
}
}
class DownloadedCodecFile {
final String localPath;
final int fileSizeBytes;
const DownloadedCodecFile({
required this.localPath,
required this.fileSizeBytes,
});
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
export 'image_codec_session_stub.dart'
if (dart.library.io) 'image_codec_session_io.dart';
+606
View File
@@ -0,0 +1,606 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart';
import '../models/image_codec_support.dart';
import '../widgets/image_send_codec_binding.dart' show kImageCodecSquareSize;
import 'image_codec_backend.dart';
import 'entropy_tables.dart';
/// Owns a long-lived [Isolate] and the native codec session inside it.
///
/// A decode is 1385 GFLOP over 886M parameters. It must never run on the root
/// isolate: it would freeze the UI and, worse, stall the BLE notify stream that
/// `MeshCoreConnector._handleFrame()` feeds on, dropping mesh traffic while a
/// picture renders.
///
/// The isolate is spawned once per model load and reused, because loading the
/// model is the expensive part (~833 MB int8). This is the same amortisation
/// `TranslationService._ensureContext` gets for free from llamadart, which owns
/// its own worker thread. Do NOT switch this to `Isolate.run` per call.
///
/// Message protocol (all maps, all `type`-tagged):
/// worker -> host `ready` {port: SendPort, backend: String,
/// bitstream: bool, entropy: bool, tables: bool}
/// `fatal` {error: String} during startup only
/// `progress` {id: int, value: double}
/// `result` {id: int, bytes: Uint8List}
/// `released` {id: int}
/// `error` {id: int, error: String, stack: String?}
/// host -> worker `job` {id, op: 'encode'|'decode'|'decodeLatent',
/// bytes | latents, rate, size}
/// `release` {id, which: 'decoder'|'entropy'|'both'}
/// `cancel` {}
/// `shutdown` {}
///
/// ## Two sessions, two lifetimes
///
/// The backend holds the 2.16 GiB decoder and the 67 MB entropy graph
/// independently, and [release] is how the host sheds one without killing the
/// other. It exists because `decode()` MUST drop the entropy graph before it
/// creates the decoder holding both at once is ~2.2 GiB plus 67 MB of arena
/// on a phone and because memory pressure wants the big half gone while the
/// small half (all that `encode()` needs) can survive.
///
/// ## Plugin channels on a background isolate
///
/// The ONNX backend is a *plugin*, reached over a platform channel, and a plain
/// spawned isolate has no binary messenger every `invokeMethod` would throw
/// until `BackgroundIsolateBinaryMessenger.ensureInitialized` is handed a
/// [RootIsolateToken] captured on the root isolate. That token is part of the
/// boot payload below. Consequence: [spawn] must be called from the root
/// isolate. It is (via `ImageCodecService`), and if that ever changes the token
/// is null and the worker fails loudly at startup instead of on first inference.
class ImageCodecSession {
final Isolate _isolate;
final SendPort _toWorker;
final ReceivePort _fromWorker;
final ReceivePort _errors;
/// Name of the backend that actually loaded, for logs and error strings.
final String backendName;
/// Whether the loaded backend can turn bytes into a picture, as opposed to
/// only latents into a picture. True only when the bundle carried the entropy
/// graph and the CDF tables AND the backend implements the entropy path.
final bool supportsBitstreamCodec;
/// Whether the bundle handed to [spawn] carried an entropy-side graph.
final bool hasEntropyGraph;
/// Whether the bundle handed to [spawn] carried a CDF table file.
final bool hasTables;
final Map<int, _PendingJob> _pending = {};
final Map<int, Completer<void>> _pendingReleases = {};
int _nextJobId = 1;
bool _disposed = false;
ImageCodecSession._({
required Isolate isolate,
required SendPort toWorker,
required ReceivePort fromWorker,
required ReceivePort errors,
required this.backendName,
required this.supportsBitstreamCodec,
required this.hasEntropyGraph,
required this.hasTables,
}) : _isolate = isolate,
_toWorker = toWorker,
_fromWorker = fromWorker,
_errors = errors;
/// Spawns the worker and blocks until the backend has *validated* [bundle].
///
/// Cheap by design: `load()` parses the CDF tables (813 KB) and checks that
/// every path exists, and creates NO ORT session. Sessions are created lazily
/// by the first operation that needs one, which is what lets a send pay for
/// the 67 MB entropy graph alone instead of the 2.16 GiB decoder as well.
///
/// Throws [ImageCodecUnimplemented] when no inference backend is compiled
/// into the build, or whatever the backend threw while loading.
static Future<ImageCodecSession> spawn(ImageCodecBundle bundle) async {
final fromWorker = ReceivePort();
final errors = ReceivePort();
final handshake = Completer<Map<String, Object?>>();
ImageCodecSession? session;
fromWorker.listen((message) {
if (message is! Map) return;
final map = message.cast<String, Object?>();
final active = session;
if (active != null) {
active._handleMessage(map);
} else if (!handshake.isCompleted) {
handshake.complete(map);
}
});
errors.listen((message) {
final description = message is List && message.isNotEmpty
? message.first.toString()
: message.toString();
final active = session;
if (active != null) {
active._failAll(StateError('Image codec isolate died: $description'));
} else if (!handshake.isCompleted) {
handshake.complete({'type': 'fatal', 'error': description});
}
});
late final Isolate isolate;
try {
isolate = await Isolate.spawn<List<Object?>>(
_codecWorkerMain,
_bootPayload(
bundle,
fromWorker.sendPort,
// Null when spawn() was not called from the root isolate. The worker
// treats that as a fatal startup error rather than limping on to fail
// at the first invokeMethod.
RootIsolateToken.instance,
),
errorsAreFatal: true,
onError: errors.sendPort,
debugName: 'image-codec',
);
} catch (_) {
fromWorker.close();
errors.close();
rethrow;
}
Map<String, Object?> reply;
try {
reply = await handshake.future;
} catch (_) {
isolate.kill(priority: Isolate.immediate);
fromWorker.close();
errors.close();
rethrow;
}
if (reply['type'] != 'ready') {
isolate.kill(priority: Isolate.immediate);
fromWorker.close();
errors.close();
final detail = reply['error']?.toString() ?? 'unknown startup failure';
if (reply['unimplemented'] == true) {
throw ImageCodecUnimplemented(detail);
}
throw StateError('Image codec failed to start: $detail');
}
return session = ImageCodecSession._(
isolate: isolate,
toWorker: reply['port'] as SendPort,
fromWorker: fromWorker,
errors: errors,
backendName: reply['backend'] as String? ?? 'unknown',
supportsBitstreamCodec: reply['bitstream'] == true,
hasEntropyGraph: reply['entropy'] == true,
hasTables: reply['tables'] == true,
);
}
/// Runs the synthesis half: `y_hat` -> packed 8-bit RGB.
///
/// This is the one inference path that actually works today. [encode] and
/// [decode] fail with [ImageCodecEntropyPathMissing] until the entropy-side
/// graph and the rANS coder land.
Future<Uint8List> decodeLatent(
Float32List yHat, {
void Function(double progress)? onProgress,
}) {
return _submit(
op: 'decodeLatent',
latents: yHat,
ratePoint: kShippingAeicRatePoint,
resolution: kImageCodecSquareSize,
onProgress: onProgress,
);
}
Future<Uint8List> encode(
Uint8List rgbBytes,
AeicRatePoint ratePoint,
int resolution, {
void Function(double progress)? onProgress,
}) {
return _submit(
op: 'encode',
bytes: rgbBytes,
ratePoint: ratePoint,
resolution: resolution,
onProgress: onProgress,
);
}
Future<Uint8List> decode(
Uint8List bitstream,
AeicRatePoint ratePoint,
int resolution, {
void Function(double progress)? onProgress,
}) {
return _submit(
op: 'decode',
bytes: bitstream,
ratePoint: ratePoint,
resolution: resolution,
onProgress: onProgress,
);
}
Future<Uint8List> _submit({
required String op,
Uint8List? bytes,
Float32List? latents,
required AeicRatePoint ratePoint,
required int resolution,
void Function(double progress)? onProgress,
}) {
if (_disposed) {
return Future.error(StateError('Image codec session disposed.'));
}
final id = _nextJobId++;
final job = _PendingJob(onProgress: onProgress);
_pending[id] = job;
_toWorker.send(<String, Object?>{
'type': 'job',
'id': id,
'op': op,
'bytes': ?bytes,
'latents': ?latents,
'rate': ratePoint.wireValue,
'size': resolution,
});
return job.completer.future;
}
/// Drops one or both ORT sessions without killing the isolate.
///
/// The paths stay recorded inside the backend, so a released session is
/// re-created on the next call that needs it. Completes when the worker has
/// acknowledged, so a caller can rely on the memory being back before it
/// creates the other session.
///
/// Queued behind any in-flight job, like every other command: releasing the
/// decoder out from under a running synthesis pass would crash ORT.
Future<void> release({bool decoder = false, bool entropy = false}) {
if (!decoder && !entropy) return Future<void>.value();
if (_disposed) return Future<void>.value();
final id = _nextJobId++;
final completer = Completer<void>();
_pendingReleases[id] = completer;
_toWorker.send(<String, Object?>{
'type': 'release',
'id': id,
'which': decoder && entropy
? 'both'
: decoder
? 'decoder'
: 'entropy',
});
return completer.future;
}
/// Cooperative cancel.
///
/// The worker sets a flag that the backend polls at stage boundaries. A
/// backend sitting inside one long blocking native call cannot be
/// interrupted this way for a hard stop, call [dispose], which kills the
/// isolate outright and frees the model.
void cancel() {
if (_disposed) return;
_toWorker.send(const <String, Object?>{'type': 'cancel'});
}
void _handleMessage(Map<String, Object?> message) {
final id = message['id'];
final job = id is int ? _pending[id] : null;
switch (message['type']) {
case 'progress':
final value = message['value'];
if (value is num) {
job?.onProgress?.call(value.toDouble().clamp(0.0, 1.0));
}
case 'result':
if (id is int) _pending.remove(id);
final bytes = message['bytes'];
if (bytes is Uint8List) {
job?.completer.complete(bytes);
} else {
job?.completer.completeError(
StateError('Image codec returned no bytes.'),
);
}
case 'released':
if (id is int) {
final release = _pendingReleases.remove(id);
if (release != null && !release.isCompleted) {
release.complete();
}
}
case 'error':
if (id is int) {
_pending.remove(id);
// A release can fail too; never leave its caller hanging.
final release = _pendingReleases.remove(id);
if (release != null && !release.isCompleted) {
release.complete();
}
}
job?.completer.completeError(
StateError(message['error']?.toString() ?? 'Image codec failed.'),
);
}
}
void _failAll(Object error) {
final jobs = _pending.values.toList();
_pending.clear();
for (final job in jobs) {
if (!job.completer.isCompleted) {
job.completer.completeError(error);
}
}
// Releases resolve rather than fail: the isolate dying IS the memory being
// freed, which is all the caller was waiting for.
final releases = _pendingReleases.values.toList();
_pendingReleases.clear();
for (final release in releases) {
if (!release.isCompleted) {
release.complete();
}
}
}
/// Kills the isolate and frees the model's resident memory.
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_toWorker.send(const <String, Object?>{'type': 'shutdown'});
// Give the worker one event-loop turn to release the native session
// cleanly, then take the memory back regardless.
await Future<void>.delayed(const Duration(milliseconds: 50));
_isolate.kill(priority: Isolate.immediate);
_failAll(StateError('Image codec session disposed.'));
_fromWorker.close();
_errors.close();
}
}
class _PendingJob {
final Completer<Uint8List> completer = Completer<Uint8List>();
final void Function(double progress)? onProgress;
_PendingJob({this.onProgress});
}
/// The positional payload handed to the codec worker isolate.
///
/// Built and parsed in ONE place on purpose. When these were two hand-written
/// lists, `entropyDecodeGraphPath` was added to the bundle but never to the
/// list, so the worker rebuilt a bundle that could not decode while
/// `canDecode`, computed on the main isolate where the path existed, said it
/// could. Every decode threw `ImageCodecBundleIncomplete` and told the user to
/// re-download a model that was already correct. The whole suite stayed green,
/// because both halves were individually right.
///
/// APPEND new fields; never insert. The list is positional and the casts are
/// permissive enough that a shifted `tablesPath` would be read as the rate
/// point rather than throwing.
List<Object?> _bootPayload(
ImageCodecBundle bundle,
SendPort reply,
RootIsolateToken? rootToken,
) => <Object?>[
reply, // 0
bundle.decoderGraphPath, // 1
bundle.entropyGraphPath, // 2
bundle.tablesPath, // 3
bundle.ratePoint.wireValue, // 4
rootToken, // 5
bundle.entropyDecodeGraphPath, // 6
];
/// Rebuilds the bundle from [_bootPayload]. Tolerates a short list so a
/// truncated or older message degrades to "cannot decode" instead of throwing a
/// RangeError inside the isolate, where it would surface as an opaque spawn
/// failure.
ImageCodecBundle _bundleFromBootPayload(List<Object?> boot) => ImageCodecBundle(
decoderGraphPath: boot[1] as String,
entropyGraphPath: boot[2] as String?,
tablesPath: boot[3] as String?,
ratePoint: parseAeicRatePoint(boot[4] as int? ?? -1),
entropyDecodeGraphPath: boot.length > 6 ? boot[6] as String? : null,
);
/// Test seams for the boot payload. Not for production use see
/// `test/services/image_codec_boot_payload_test.dart`, which exists because
/// this connection has broken twice.
@visibleForTesting
List<Object?> debugBootPayloadFor(ImageCodecBundle bundle) =>
_bootPayload(bundle, _NullSendPort(), null);
@visibleForTesting
ImageCodecBundle debugBundleFromBootPayload(List<Object?> boot) =>
_bundleFromBootPayload(boot);
/// Stand-in so [debugBootPayloadFor] needs no live isolate.
class _NullSendPort implements SendPort {
@override
void send(Object? message) {}
@override
bool operator ==(Object other) => identical(this, other);
@override
int get hashCode => 0;
}
/// Entry point of the codec worker isolate.
Future<void> _codecWorkerMain(List<Object?> boot) async {
final reply = boot[0] as SendPort;
final bundle = _bundleFromBootPayload(boot);
final rootToken = boot[5] as RootIsolateToken?;
if (rootToken == null) {
reply.send(<String, Object?>{
'type': 'fatal',
'error':
'RootIsolateToken was unavailable, so the ONNX plugin channel cannot '
'be reached from this isolate. ImageCodecSession.spawn() must be '
'called from the root isolate.',
});
return;
}
// Without this the backend's very first invokeMethod throws. Must happen
// before createImageCodecBackend(), which constructs the plugin wrapper.
BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);
final backend = createImageCodecBackend();
if (backend == null) {
reply.send(<String, Object?>{
'type': 'fatal',
'unimplemented': true,
'error':
'no inference backend is compiled into this build '
'(see lib/services/image_codec_backend.dart)',
});
return;
}
// The bitstream path is a seam so that image_codec_backend.dart can compile
// for web, where dart:io does not exist. This worker isolate is native-only,
// so it is the correct place to close it -- and it must happen BEFORE
// backend.load(), because load() reads supportsBitstreamCodec to decide
// whether the codec can encode or decode at all. Left unassigned, the whole
// entropy path is dead even with kImageCodecBitstreamPathAvailable true.
imageCodecRansCoderBuilder ??= (path) async =>
AeicRansCoders(EntropyTables.parse(await File(path).readAsBytes()));
try {
await backend.load(bundle);
} catch (error) {
reply.send(<String, Object?>{'type': 'fatal', 'error': error.toString()});
await backend.dispose();
return;
}
final commands = ReceivePort();
var cancelRequested = false;
var queue = Future<void>.value();
reply.send(<String, Object?>{
'type': 'ready',
'port': commands.sendPort,
'backend': backend.name,
// `bitstream` is the backend's own answer (does this build have the entropy
// path at all?); `entropy`/`tables` describe what THIS INSTALL supplied. A
// pre-bundle install reports bitstream:true, tables:false a state the
// service must surface as "re-download", not as "your build cannot".
'bitstream': backend.supportsBitstreamCodec,
'entropy': bundle.entropyGraphPath != null,
'tables': bundle.tablesPath != null,
});
commands.listen((message) {
if (message is! Map) return;
final map = message.cast<String, Object?>();
switch (map['type']) {
case 'cancel':
// Handled on the event loop, so it lands between backend stages.
cancelRequested = true;
case 'shutdown':
commands.close();
unawaited(backend.dispose());
case 'release':
// Queued behind any running job: tearing an ORT session down while it
// is executing is a native crash, not an exception.
queue = queue.then((_) async {
final id = map['id'] as int?;
try {
final which = map['which'];
if (which == 'decoder' || which == 'both') {
await backend.releaseDecoderSession();
}
if (which == 'entropy' || which == 'both') {
await backend.releaseEntropySession();
}
reply.send(<String, Object?>{'type': 'released', 'id': id});
} catch (error) {
reply.send(<String, Object?>{
'type': 'error',
'id': id,
'error': error.toString(),
});
}
});
case 'job':
queue = queue.then((_) async {
cancelRequested = false;
final id = map['id'] as int;
try {
final ratePoint = parseAeicRatePoint(
map['rate'] as int? ?? kShippingAeicRatePoint.wireValue,
);
final resolution = map['size'] as int? ?? kImageCodecSquareSize;
void onProgress(double value) {
reply.send(<String, Object?>{
'type': 'progress',
'id': id,
'value': value,
});
}
bool shouldCancel() => cancelRequested;
final Uint8List result;
switch (map['op']) {
case 'decodeLatent':
result = await backend.decodeLatentToRgb(
yHat: map['latents'] as Float32List,
onProgress: onProgress,
shouldCancel: shouldCancel,
);
case 'encode':
result = await backend.encode(
rgbBytes: map['bytes'] as Uint8List,
ratePoint: ratePoint,
resolution: resolution,
onProgress: onProgress,
shouldCancel: shouldCancel,
);
case 'decode':
result = await backend.decode(
bitstream: map['bytes'] as Uint8List,
ratePoint: ratePoint,
resolution: resolution,
onProgress: onProgress,
shouldCancel: shouldCancel,
);
default:
throw StateError('Unknown codec op: ${map['op']}');
}
reply.send(<String, Object?>{
'type': 'result',
'id': id,
'bytes': result,
});
} catch (error, stackTrace) {
reply.send(<String, Object?>{
'type': 'error',
'id': id,
'error': error.toString(),
'stack': stackTrace.toString(),
});
}
});
}
});
}
@@ -0,0 +1,53 @@
import 'dart:typed_data';
import '../models/image_codec_support.dart';
/// Web stand-in. There is no isolate and no native runtime on web, so the
/// session cannot exist; `ImageCodecService` gates on `kIsWeb` long before it
/// would reach here.
class ImageCodecSession {
ImageCodecSession._();
static Future<ImageCodecSession> spawn(ImageCodecBundle bundle) async {
throw UnsupportedError('The image codec is not supported on web.');
}
String get backendName => 'unsupported';
bool get supportsBitstreamCodec => false;
bool get hasEntropyGraph => false;
bool get hasTables => false;
Future<void> release({bool decoder = false, bool entropy = false}) async {}
Future<Uint8List> decodeLatent(
Float32List yHat, {
void Function(double progress)? onProgress,
}) async {
throw UnsupportedError('The image codec is not supported on web.');
}
Future<Uint8List> encode(
Uint8List rgbBytes,
AeicRatePoint ratePoint,
int resolution, {
void Function(double progress)? onProgress,
}) async {
throw UnsupportedError('The image codec is not supported on web.');
}
Future<Uint8List> decode(
Uint8List bitstream,
AeicRatePoint ratePoint,
int resolution, {
void Function(double progress)? onProgress,
}) async {
throw UnsupportedError('The image codec is not supported on web.');
}
void cancel() {}
Future<void> dispose() async {}
}
@@ -0,0 +1,89 @@
import 'dart:convert';
import '../models/image_codec_support.dart';
import '../storage/prefs_manager.dart';
import '../utils/app_logger.dart';
/// Persistence seam for [ImageCodecPreferences].
///
/// WHY THIS EXISTS. The app stack spec puts these five fields on `AppSettings`
/// (`imageCodecEnabled`, `imageCodecSelectedModelId`, `imageCodecModelSourceUrl`,
/// `imageCodecRatePoint`, `imageCodecDownloadedModels`) with matching setters on
/// `AppSettingsService` exactly like the translation block. That is still the
/// right end state, but `app_settings.dart` and `app_settings_service.dart` are
/// outside this workstream's file set, so the service talks to this interface
/// instead of `AppSettingsService`.
///
/// Migration is mechanical: add the fields to `AppSettings` (the JSON keys in
/// [ImageCodecPreferences.toJson] are already the `image_codec_*` snake_case
/// names), then replace [PrefsImageCodecSettingsStore] with a thin adapter over
/// `AppSettingsService`. No `ImageCodecService` code changes.
abstract class ImageCodecSettingsStore {
ImageCodecPreferences get preferences;
/// Loads persisted preferences. Safe to call more than once.
Future<void> load();
Future<void> save(ImageCodecPreferences preferences);
}
/// Default store: one SharedPreferences key holding the whole block as JSON.
class PrefsImageCodecSettingsStore implements ImageCodecSettingsStore {
static const String _key = 'image_codec_settings';
ImageCodecPreferences _preferences = const ImageCodecPreferences();
bool _loaded = false;
@override
ImageCodecPreferences get preferences => _preferences;
@override
Future<void> load() async {
if (_loaded) return;
_loaded = true;
try {
final raw = PrefsManager.instance.getString(_key);
if (raw == null) return;
final json = jsonDecode(raw);
if (json is Map<String, dynamic>) {
_preferences = ImageCodecPreferences.fromJson(json);
}
} catch (error) {
// Matches AppSettingsService.loadSettings: a corrupt blob falls back to
// defaults rather than blocking startup.
appLogger.warn('Image codec settings load failed: $error');
_preferences = const ImageCodecPreferences();
}
}
@override
Future<void> save(ImageCodecPreferences preferences) async {
_preferences = preferences;
_loaded = true;
try {
await PrefsManager.instance.setString(_key, jsonEncode(preferences.toJson()));
} catch (error) {
appLogger.warn('Image codec settings save failed: $error');
}
}
}
/// Non-persisting store for tests and widget previews.
class InMemoryImageCodecSettingsStore implements ImageCodecSettingsStore {
ImageCodecPreferences _preferences;
InMemoryImageCodecSettingsStore([
this._preferences = const ImageCodecPreferences(),
]);
@override
ImageCodecPreferences get preferences => _preferences;
@override
Future<void> load() async {}
@override
Future<void> save(ImageCodecPreferences preferences) async {
_preferences = preferences;
}
}
+498
View File
@@ -0,0 +1,498 @@
// Pure-Dart port of the AEIC rANS entropy coder.
//
// Ported from the C++ reference in `aeic/src/cpp/rans/` (rans.h, rans_byte.h,
// rans.cpp), which is itself ryg_rans + CompressAI's unbounded-index range
// coding. The port must be BYTE-IDENTICAL to the C++ coder: a single differing
// byte desynchronises rANS and silently corrupts most of the image. The golden
// vectors under `test/services/golden/` pin that down.
//
// Notes on the port:
// * Dart `int` is 64-bit and signed; the C++ state is `uint32_t`. Every place
// the C++ relies on 32-bit behaviour is either provably in-range (the state
// invariant keeps `x < 2^31`) or masked explicitly with `& 0xFFFFFFFF`.
// * `>>` on a negative Dart int is arithmetic. All shifted values here are
// non-negative by construction; `>>>` is used where the shift consumes the
// state so the intent is unambiguous.
//
// Format recap (see `results/rans_port_spec.md`):
// * precision 16, bypassPrecision 2, RANS_L = 1 << 23, streamParts 2.
// * Each `encodeWithIndexes` call is split evenly across the sub-streams:
// part p covers `[p * (n ~/ parts), ...)`, the last part taking the
// remainder. Every part accumulates across all calls; flush happens once.
// * A sub-stream's first 4 bytes are the final rANS state, little-endian.
// * Container: `flag = ((nParts - 1) << 4) | (hdrLen == 2 ? 1 : 0)`, then the
// lengths of the first `nParts - 1` sub-streams (hdrLen bytes each, LE),
// then the sub-streams back to back.
library;
import 'dart:typed_data';
import 'entropy_tables.dart';
/// Lower bound of the rANS normalisation interval (`RANS_BYTE_L`).
const int kRansLowerBound = 1 << 23;
/// Thrown when a bitstream cannot be interpreted.
class RansFormatException implements Exception {
RansFormatException(this.message);
final String message;
@override
String toString() => 'RansFormatException: $message';
}
/// Splits the shipped bytes into rANS sub-streams. Mirror of
/// `RansDecoder::set_stream`.
List<Uint8List> parseRansContainer(Uint8List stream) {
if (stream.isEmpty) {
throw RansFormatException('empty stream');
}
final int flag = stream[0];
final int nStreams = (flag >> 4) + 1;
final int hdr = (flag & 0x0F) == 1 ? 2 : 4;
var off = 1;
final List<int> sizes = <int>[];
var total = 0;
for (var i = 0; i < nStreams - 1; i++) {
if (off + hdr > stream.length) {
throw RansFormatException('truncated sub-stream size table');
}
var sz = 0;
for (var b = 0; b < hdr; b++) {
sz |= stream[off + b] << (8 * b);
}
off += hdr;
sizes.add(sz);
total += sz;
}
final int last = stream.length - off - total;
if (last < 0) {
throw RansFormatException('sub-stream sizes exceed the stream length');
}
sizes.add(last);
final List<Uint8List> parts = <Uint8List>[];
var p = off;
for (final int sz in sizes) {
if (p + sz > stream.length) {
throw RansFormatException('truncated sub-stream');
}
parts.add(Uint8List.sublistView(stream, p, p + sz));
p += sz;
}
return parts;
}
/// Assembles sub-streams into the bytes that go on the air.
Uint8List buildRansContainer(List<Uint8List> parts) {
if (parts.isEmpty) {
throw RansFormatException('no sub-streams');
}
if (parts.length > 16) {
throw RansFormatException('too many sub-streams (${parts.length})');
}
var maximum = 0;
for (var i = 0; i < parts.length - 1; i++) {
if (parts[i].length > maximum) maximum = parts[i].length;
}
final int hdr = maximum > 65535 ? 4 : 2;
final int flag = ((parts.length - 1) << 4) | (hdr == 2 ? 1 : 0);
var total = 1 + hdr * (parts.length - 1);
for (final Uint8List p in parts) {
total += p.length;
}
final Uint8List out = Uint8List(total);
out[0] = flag;
var off = 1;
for (var i = 0; i < parts.length - 1; i++) {
final int n = parts[i].length;
for (var b = 0; b < hdr; b++) {
out[off + b] = (n >> (8 * b)) & 0xFF;
}
off += hdr;
}
for (final Uint8List p in parts) {
out.setRange(off, off + p.length, p);
off += p.length;
}
return out;
}
/// Growable (start, range) entry list, stored interleaved in one Int32List.
/// `range == 0` is the bypass sentinel: `start` then carries the raw bits.
class _EntryBuffer {
Int32List _data = Int32List(2048);
int _length = 0;
int get length => _length;
void add(int start, int range) {
final int need = (_length + 1) * 2;
if (need > _data.length) {
final Int32List bigger = Int32List(_data.length * 2);
bigger.setRange(0, _data.length, _data);
_data = bigger;
}
_data[_length * 2] = start;
_data[_length * 2 + 1] = range;
_length++;
}
int startAt(int i) => _data[i * 2];
int rangeAt(int i) => _data[i * 2 + 1];
void clear() => _length = 0;
}
/// Growable byte sink used for the reversed encoder output.
class _ByteSink {
Uint8List _data = Uint8List(4096);
int _length = 0;
void add(int byte) {
if (_length == _data.length) {
final Uint8List bigger = Uint8List(_data.length * 2);
bigger.setRange(0, _data.length, _data);
_data = bigger;
}
_data[_length++] = byte & 0xFF;
}
/// Returns the bytes in reverse of the order they were added, which is the
/// order they appear in the sub-stream.
Uint8List reversedBytes() {
final Uint8List out = Uint8List(_length);
for (var i = 0; i < _length; i++) {
out[i] = _data[_length - 1 - i];
}
return out;
}
}
/// rANS encoder. Call [encodeWithIndexes] once per stage in format order
/// (z, y0, y1, y2, y3), then [finish] exactly once.
class RansEncoder {
factory RansEncoder(EntropyTables tables, {int? streamParts}) =>
RansEncoder._(tables, streamParts ?? tables.streamParts);
RansEncoder._(EntropyTables tables, int streamParts)
: tables = tables,
streamParts = streamParts,
_precision = tables.precision,
_bypassPrecision = tables.bypassPrecision,
_entries = List<_EntryBuffer>.generate(
streamParts,
(_) => _EntryBuffer(),
);
final EntropyTables tables;
final int streamParts;
final int _precision;
final int _bypassPrecision;
final List<_EntryBuffer> _entries;
bool _finished = false;
int get _maxBypassVal => (1 << _bypassPrecision) - 1;
/// Accumulates one encode call. [symbols] and [indexes] must be the same
/// length; an index `< 0` emits nothing at all.
///
/// Never pass an odd-length array: the reference splitter (and therefore the
/// on-air format) mis-sizes the last part's index vector in that case.
void encodeWithIndexes(
List<int> symbols,
List<int> indexes,
int cdfGroupIndex,
) {
if (_finished) {
throw StateError('RansEncoder.finish() has already been called');
}
if (symbols.length != indexes.length) {
throw ArgumentError(
'symbols (${symbols.length}) and indexes (${indexes.length}) differ',
);
}
if (cdfGroupIndex < 0 || cdfGroupIndex >= tables.groups.length) {
throw ArgumentError('no CDF group $cdfGroupIndex');
}
final CdfGroup group = tables.groups[cdfGroupIndex];
final int total = symbols.length;
final int each = total ~/ streamParts;
for (var p = 0; p < streamParts; p++) {
final int lo = p * each;
final int hi = p == streamParts - 1 ? total : lo + each;
_push(_entries[p], symbols, indexes, lo, hi, group);
}
}
void _push(
_EntryBuffer out,
List<int> symbols,
List<int> indexes,
int lo,
int hi,
CdfGroup group,
) {
final Int32List cdf = group.quantizedCdf;
final Int32List cdfLength = group.cdfLength;
final Int32List offsets = group.offset;
final int width = group.cdfWidth;
final int maxBypassVal = _maxBypassVal;
final int bypassPrecision = _bypassPrecision;
for (var i = lo; i < hi; i++) {
final int cdfIdx = indexes[i];
if (cdfIdx < 0) {
continue;
}
if (cdfIdx >= group.numCdfs) {
throw RansFormatException(
'index $cdfIdx out of range (${group.numCdfs} CDF rows)',
);
}
final int maxValue = cdfLength[cdfIdx] - 2;
var value = symbols[i] - offsets[cdfIdx];
var rawVal = 0;
if (value < 0) {
rawVal = -2 * value - 1;
value = maxValue;
} else if (value >= maxValue) {
rawVal = 2 * (value - maxValue);
value = maxValue;
}
final int base = cdfIdx * width;
final int start = cdf[base + value];
out.add(start, cdf[base + value + 1] - start);
if (value == maxValue) {
// Bypass mode: raw bits, `bypassPrecision` at a time.
var nBypass = 0;
while ((rawVal >> (nBypass * bypassPrecision)) != 0) {
nBypass++;
}
var val = nBypass;
while (val >= maxBypassVal) {
out.add(maxBypassVal, 0);
val -= maxBypassVal;
}
out.add(val, 0);
for (var j = 0; j < nBypass; j++) {
out.add((rawVal >> (j * bypassPrecision)) & maxBypassVal, 0);
}
}
}
}
/// Flushes every sub-stream and returns the container bytes.
Uint8List finish() {
if (_finished) {
throw StateError('RansEncoder.finish() has already been called');
}
_finished = true;
final List<Uint8List> parts = <Uint8List>[
for (final _EntryBuffer e in _entries) _flush(e),
];
return buildRansContainer(parts);
}
/// Discards accumulated entries so the encoder can be reused.
void reset() {
for (final _EntryBuffer e in _entries) {
e.clear();
}
_finished = false;
}
Uint8List _flush(_EntryBuffer entries) {
final _ByteSink sink = _ByteSink();
// The state is always in [2^23, 2^31); native Dart ints need no masking,
// but the emission path is written to stay explicitly byte-wise anyway.
var x = kRansLowerBound;
final int bypassXMax = (1 << (_precision - _bypassPrecision)) << 15;
for (var k = entries.length - 1; k >= 0; k--) {
final int range = entries.rangeAt(k);
final int start = entries.startAt(k);
if (range != 0) {
final int xMax = range << 15;
while (x >= xMax) {
sink.add(x & 0xFF);
x = x >>> 8;
}
x = ((x ~/ range) << _precision) + (x % range) + start;
} else {
while (x >= bypassXMax) {
sink.add(x & 0xFF);
x = x >>> 8;
}
x = ((x << _bypassPrecision) | start) & 0xFFFFFFFF;
}
}
// RansEncFlush writes the 32-bit state little-endian at the front of the
// stream, i.e. in emission order it is the high byte first.
sink.add((x >>> 24) & 0xFF);
sink.add((x >>> 16) & 0xFF);
sink.add((x >>> 8) & 0xFF);
sink.add(x & 0xFF);
return sink.reversedBytes();
}
}
/// rANS decoder. Decoding is INCREMENTAL: construct once over the whole
/// container, then call [decodeStream] per stage in the same order the encoder
/// used (z, y0, y1, y2, y3). Each call resumes the sub-stream states where the
/// previous one left off, because stage i's indexes are unknown until the
/// earlier stages have been decoded and run back through the network.
class RansDecoder {
factory RansDecoder(
EntropyTables tables,
Uint8List stream, {
int? streamParts,
}) => RansDecoder._(tables, stream, streamParts ?? tables.streamParts);
RansDecoder._(EntropyTables tables, Uint8List stream, int expected)
: tables = tables,
_precision = tables.precision,
_bypassPrecision = tables.bypassPrecision,
_parts = parseRansContainer(stream) {
if (_parts.length != expected) {
throw RansFormatException(
'container has ${_parts.length} sub-streams, expected $expected',
);
}
_states = List<int>.filled(_parts.length, 0);
_ptrs = List<int>.filled(_parts.length, 0);
for (var i = 0; i < _parts.length; i++) {
final Uint8List p = _parts[i];
if (p.length < 4) {
throw RansFormatException('sub-stream $i is shorter than 4 bytes');
}
_states[i] = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24);
_ptrs[i] = 4;
}
}
final EntropyTables tables;
final int _precision;
final int _bypassPrecision;
final List<Uint8List> _parts;
late final List<int> _states;
late final List<int> _ptrs;
int get streamParts => _parts.length;
/// Decodes one stage. Returns one symbol per entry of [indexes]; positions
/// whose index is `< 0` yield a literal 0 and consume nothing.
Int16List decodeStream(List<int> indexes, int cdfGroupIndex) {
if (cdfGroupIndex < 0 || cdfGroupIndex >= tables.groups.length) {
throw ArgumentError('no CDF group $cdfGroupIndex');
}
final CdfGroup group = tables.groups[cdfGroupIndex];
final Int32List cdf = group.quantizedCdf;
final Int32List cdfLength = group.cdfLength;
final Int32List offsets = group.offset;
final int width = group.cdfWidth;
final int mask = (1 << _precision) - 1;
final int bypassPrecision = _bypassPrecision;
final int maxBypassVal = (1 << bypassPrecision) - 1;
final int bypassMask = maxBypassVal;
final int total = indexes.length;
final int nParts = _parts.length;
final int each = total ~/ nParts;
final Int16List out = Int16List(total);
for (var pi = 0; pi < nParts; pi++) {
final int lo = pi * each;
final int hi = pi == nParts - 1 ? total : lo + each;
final Uint8List buf = _parts[pi];
var x = _states[pi];
var ptr = _ptrs[pi];
for (var i = lo; i < hi; i++) {
final int cdfIdx = indexes[i];
if (cdfIdx < 0) {
out[i] = 0;
continue;
}
if (cdfIdx >= group.numCdfs) {
throw RansFormatException(
'index $cdfIdx out of range (${group.numCdfs} CDF rows)',
);
}
final int n = cdfLength[cdfIdx];
final int maxValue = n - 2;
final int base = cdfIdx * width;
final int cum = x & mask;
// upper_bound(row[0:n], cum) - 1
var loo = 0;
var hii = n;
while (loo < hii) {
final int mid = (loo + hii) >> 1;
if (cdf[base + mid] > cum) {
hii = mid;
} else {
loo = mid + 1;
}
}
final int s = loo - 1;
if (s < 0 || s >= n - 1) {
throw RansFormatException('corrupt stream: symbol $s out of range');
}
final int start = cdf[base + s];
final int range = cdf[base + s + 1] - start;
x = (range * (x >>> _precision) + (x & mask) - start) & 0xFFFFFFFF;
while (x < kRansLowerBound) {
if (ptr >= buf.length) {
throw RansFormatException('sub-stream $pi exhausted');
}
x = ((x << 8) | buf[ptr]) & 0xFFFFFFFF;
ptr++;
}
var value = s;
if (value == maxValue) {
// Bypass mode. Note the renormalisation here is a single `if`, not a
// loop -- that asymmetry with the symbol path is part of the format.
int getBits() {
final int v = x & bypassMask;
x = x >>> bypassPrecision;
if (x < kRansLowerBound) {
if (ptr >= buf.length) {
throw RansFormatException('sub-stream $pi exhausted');
}
x = ((x << 8) | buf[ptr]) & 0xFFFFFFFF;
ptr++;
}
return v;
}
var val = getBits();
var nBypass = val;
while (val == maxBypassVal) {
val = getBits();
nBypass += val;
}
var rawVal = 0;
for (var j = 0; j < nBypass; j++) {
rawVal |= getBits() << (j * bypassPrecision);
}
value = rawVal >> 1;
if ((rawVal & 1) != 0) {
value = -value - 1;
} else {
value += maxValue;
}
}
out[i] = value + offsets[cdfIdx];
}
_states[pi] = x;
_ptrs[pi] = ptr;
}
return out;
}
}
@@ -0,0 +1,10 @@
/// Platform-appropriate [ReceivedImageBlobStore], chosen at compile time.
///
/// `received_image_blob_store_io.dart` imports `dart:io` and
/// `package:path_provider`, so `main.dart` cannot import it directly without
/// breaking the web build. Same conditional-export shape as
/// `image_codec_file_store.dart`.
library;
export 'received_image_blob_store_factory_stub.dart'
if (dart.library.io) 'received_image_blob_store_factory_io.dart';
@@ -0,0 +1,10 @@
import 'received_image_blob_store_io.dart';
import 'received_image_store.dart';
/// File-backed store under the application support directory.
///
/// Received images have to survive a restart: the sidecar record is the only
/// evidence that a message *was* an image, so an in-memory store loses the
/// bubble as well as the pixels.
ReceivedImageBlobStore createReceivedImageBlobStore() =>
FileReceivedImageBlobStore();
@@ -0,0 +1,8 @@
import 'received_image_store.dart';
/// Web fallback: images live only for the lifetime of the tab.
///
/// The codec cannot run on web anyway (`ImageCodecService.availability` is
/// `unavailable` there), so nothing is lost that could have been rendered.
ReceivedImageBlobStore createReceivedImageBlobStore() =>
InMemoryReceivedImageBlobStore();
@@ -0,0 +1,284 @@
/// File-backed [ReceivedImageBlobStore] for every platform that has `dart:io`.
///
/// Without this, `ReceivedImageStore` falls back to
/// [InMemoryReceivedImageBlobStore] and every received image is forgotten at
/// the next app launch the sidecars go with the bytes, so even the "this
/// message was an image" record disappears.
///
/// ## Layout
///
/// ```
/// <application support>/received_images/
/// <streamId>.aeic the ~156 B bitstream
/// <streamId>.png the decoded 512x512 PNG (~400 KB)
/// <streamId>.json the sidecar record (ReceivedImageEntry.toJson)
/// <streamId>.json.tmp transient; only during an atomic sidecar write
/// ```
///
/// Application *support*, not documents: these are derived caches of a chat
/// message, not user documents, so they must not show up in the iOS Files app
/// or be swept into an iCloud backup.
///
/// ## Guarantees
///
/// * Sidecar writes are atomic (tmp file + rename). A kill mid-write can
/// leave `<id>.json.tmp` but never a truncated `<id>.json`, so
/// [readSidecars] never has to defend against half a JSON object.
/// * Every method is total: a missing file reads as null and deletes are
/// idempotent. I/O errors are swallowed and reported through the return
/// value, because a failed write must degrade the image, not crash the
/// receive path.
/// * [readSidecars] sweeps orphans `.aeic`/`.png`/`.tmp` files with no
/// surviving `.json` so a crash between "write bitstream" and "write
/// sidecar" cannot leak bytes that no budget accounts for.
/// * Stream ids are validated before they are ever concatenated into a path;
/// a hostile `../../` id cannot escape the directory.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:path_provider/path_provider.dart';
import 'received_image_store.dart';
/// Persists received-image bytes under the application support directory.
class FileReceivedImageBlobStore implements ReceivedImageBlobStore {
/// Only `[A-Za-z0-9_-]`, so a stream id can never contain `/`, `\` or `..`.
static final RegExp _safeId = RegExp(r'^[A-Za-z0-9_-]{1,64}$');
static const String _bitstreamExt = '.aeic';
static const String _pngExt = '.png';
static const String _sidecarExt = '.json';
static const String _tmpExt = '.json.tmp';
/// Overridable so tests (and any future "move my data" feature) can point the
/// store at a temp directory instead of the real support directory.
final Future<Directory> Function() _baseDirectory;
final String directoryName;
/// Resolved lazily and then cached, because [pngPath] has to be synchronous
/// (the share sheet needs a path inside a build/callback) while
/// `path_provider` is not.
String? _dirPath;
Future<String>? _pending;
FileReceivedImageBlobStore({
Future<Directory> Function()? baseDirectory,
this.directoryName = 'received_images',
}) : _baseDirectory = baseDirectory ?? getApplicationSupportDirectory;
/// Resolves and creates the directory. Safe to call repeatedly; concurrent
/// callers share one future so the directory is created exactly once.
Future<String> ensureReady() {
final cached = _dirPath;
if (cached != null) return Future<String>.value(cached);
final pending = _pending;
if (pending != null) return pending;
final started = _resolve();
_pending = started;
return started.whenComplete(() => _pending = null);
}
Future<String> _resolve() async {
final base = await _baseDirectory();
final dir = Directory('${base.path}${Platform.pathSeparator}$directoryName');
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
_dirPath = dir.path;
return dir.path;
}
Future<String?> _pathFor(String streamId, String extension) async {
if (!_safeId.hasMatch(streamId)) return null;
final dir = await ensureReady();
return '$dir${Platform.pathSeparator}$streamId$extension';
}
Future<Uint8List?> _read(String streamId, String extension) async {
try {
final path = await _pathFor(streamId, extension);
if (path == null) return null;
final file = File(path);
if (!file.existsSync()) return null;
return await file.readAsBytes();
} catch (error) {
debugPrint('received_image_blob_store: read $streamId$extension: $error');
return null;
}
}
Future<void> _write(String streamId, String extension, Uint8List bytes) async {
try {
final path = await _pathFor(streamId, extension);
if (path == null) {
debugPrint('received_image_blob_store: refusing unsafe id "$streamId"');
return;
}
await File(path).writeAsBytes(bytes, flush: true);
} catch (error) {
debugPrint('received_image_blob_store: write $streamId$extension: $error');
}
}
Future<void> _delete(String streamId, String extension) async {
try {
final path = await _pathFor(streamId, extension);
if (path == null) return;
final file = File(path);
if (file.existsSync()) await file.delete();
} catch (error) {
debugPrint('received_image_blob_store: delete $streamId$extension: $error');
}
}
Future<int?> _size(String streamId, String extension) async {
try {
final path = await _pathFor(streamId, extension);
if (path == null) return null;
final file = File(path);
if (!file.existsSync()) return null;
return await file.length();
} catch (_) {
return null;
}
}
@override
Future<void> writeBitstream(String streamId, Uint8List bytes) =>
_write(streamId, _bitstreamExt, bytes);
@override
Future<Uint8List?> readBitstream(String streamId) =>
_read(streamId, _bitstreamExt);
@override
Future<void> deleteBitstream(String streamId) =>
_delete(streamId, _bitstreamExt);
@override
Future<int?> bitstreamSize(String streamId) => _size(streamId, _bitstreamExt);
@override
Future<void> writePng(String streamId, Uint8List bytes) =>
_write(streamId, _pngExt, bytes);
@override
Future<Uint8List?> readPng(String streamId) => _read(streamId, _pngExt);
@override
Future<void> deletePng(String streamId) => _delete(streamId, _pngExt);
@override
Future<int?> pngSize(String streamId) => _size(streamId, _pngExt);
/// Atomic: write `<id>.json.tmp`, then rename over `<id>.json`. `rename` is
/// atomic within a directory on every platform we ship, so a reader either
/// sees the old record or the new one.
@override
Future<void> writeSidecar(String streamId, String json) async {
try {
final finalPath = await _pathFor(streamId, _sidecarExt);
final tmpPath = await _pathFor(streamId, _tmpExt);
if (finalPath == null || tmpPath == null) {
debugPrint('received_image_blob_store: refusing unsafe id "$streamId"');
return;
}
final tmp = File(tmpPath);
await tmp.writeAsString(json, flush: true);
await tmp.rename(finalPath);
} catch (error) {
debugPrint('received_image_blob_store: sidecar $streamId: $error');
}
}
@override
Future<void> deleteSidecar(String streamId) async {
await _delete(streamId, _sidecarExt);
await _delete(streamId, _tmpExt);
}
/// Startup scan. Also the only place orphaned bytes are reaped: anything
/// whose sidecar is gone is invisible to the store and therefore to the
/// disk budget, so it would live forever.
@override
Future<Map<String, String>> readSidecars() async {
final result = <String, String>{};
late final String dirPath;
try {
dirPath = await ensureReady();
} catch (error) {
debugPrint('received_image_blob_store: no directory: $error');
return result;
}
final dir = Directory(dirPath);
if (!dir.existsSync()) return result;
final files = <File>[];
try {
files.addAll(dir.listSync().whereType<File>());
} catch (error) {
debugPrint('received_image_blob_store: list failed: $error');
return result;
}
final orphans = <File>[];
for (final file in files) {
final name = file.uri.pathSegments.last;
if (name.endsWith(_tmpExt)) {
// A kill during a sidecar write. The previous `.json` (if any) is
// still authoritative.
orphans.add(file);
continue;
}
if (!name.endsWith(_sidecarExt)) continue;
final id = name.substring(0, name.length - _sidecarExt.length);
if (!_safeId.hasMatch(id)) continue;
try {
final raw = await file.readAsString();
// Cheap sanity gate: the store's own jsonDecode would skip it anyway,
// but a truncated record here means we should reap its bytes too.
jsonDecode(raw);
result[id] = raw;
} catch (error) {
debugPrint('received_image_blob_store: bad sidecar $id: $error');
}
}
for (final file in files) {
final name = file.uri.pathSegments.last;
if (name.endsWith(_sidecarExt) || name.endsWith(_tmpExt)) continue;
final dot = name.lastIndexOf('.');
if (dot <= 0) continue;
final id = name.substring(0, dot);
final ext = name.substring(dot);
if (ext != _bitstreamExt && ext != _pngExt) continue;
if (result.containsKey(id)) continue;
orphans.add(file);
}
for (final file in orphans) {
try {
if (file.existsSync()) await file.delete();
} catch (_) {
// Best effort; a locked file is retried on the next launch.
}
}
return result;
}
/// Null until [ensureReady] (or any async method) has resolved the directory.
/// In practice `ReceivedImageStore.load()` runs at startup and resolves it
/// long before any bubble asks for a path.
@override
String? pngPath(String streamId) {
final dir = _dirPath;
if (dir == null) return null;
if (!_safeId.hasMatch(streamId)) return null;
return '$dir${Platform.pathSeparator}$streamId$_pngExt';
}
}
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
import 'dart:math' as math;
import '../models/radio_settings.dart';
// image_chunk_transport is the single source of truth for chunk geometry: it is
// what actually writes the bytes. This file used to declare rival constants
// (payload 163 / header 2), which both disagreed with the wire format AND
// collided by name -- any library importing both failed to compile. Import and
// re-export instead, so there is exactly one declaration in the program.
import '../services/image_chunk_transport.dart'
show
imageDataChunkCount,
kImageChunkBlobBytes,
kImageChunkCapacity,
kImageChunkFirstCapacity,
kImageChunkHeaderBytes,
kImageChunkZeroMetadataBytes;
export '../services/image_chunk_transport.dart'
show
imageDataChunkCount,
kImageChunkBlobBytes,
kImageChunkBodyBytes,
kImageChunkCapacity,
kImageChunkFirstCapacity,
kImageChunkHeaderBytes,
kImageChunkZeroMetadataBytes,
kImageParityLengthBytes;
/// Extra on-air bytes added by the MeshCore transport/routing header around a
/// GRP_DATA packet.
///
/// This value is NOT derivable from this repository, so it defaults to 0 and
/// the resulting airtime is therefore a lower bound for the chunk frame itself
/// (header + payload). Callers may pass a measured value to
/// [estimateSend] once the real overhead is known. Do not guess it here.
const int kMeshCoreOnAirOverheadBytes = 0;
/// MeshCore MAX_TRANS_UNIT the largest packet that can go on air.
/// Useful as a worst-case airtime reference.
const int kMeshCoreMaxTransUnit = 255;
/// Standard LoRa time-on-air.
///
/// ## Relationship to `connector/meshcore_protocol.dart:calculateLoRaAirtime()`
///
/// That function exists and is used for *retry timeouts*; it is deliberately NOT
/// reused here, and the two must not be conflated:
///
/// * **Coding-rate domain.** `calculateLoRaAirtime()` takes the coding rate in
/// the firmware's 1..4 domain and computes `(codingRate + 4)` internally.
/// This file takes it in the 5..8 domain, matching [LoRaCodingRate.value]
/// (`cr4_5 == 5`). A raw device value may arrive in *either* domain, so
/// [normalizeCodingRate] maps 1..4 -> 5..8 before use. Passing a 5..8 value
/// to `calculateLoRaAirtime()` would silently overstate airtime by up to 60%
/// (it would compute CR 4/9..4/12), and passing a 1..4 value to
/// [loraTimeOnAir] trips its assert in debug and understates airtime in
/// release. Always normalise at the boundary.
/// * **Low-data-rate optimise.** `calculateLoRaAirtime()` takes `DE` as a
/// parameter and its one caller passes the `sf >= 11` shortcut, which is
/// wrong for SF11/BW250 and SF12/BW500 (Tsym is 8.192 ms there, below the
/// 16 ms threshold). This file derives `DE` from `Tsym > 16 ms`, per the
/// datasheet. See the LDRO tests in `test/lora_airtime_test.dart`.
/// * **Precision and guards.** `calculateLoRaAirtime()` returns whole
/// milliseconds and will throw `Unsupported operation: Infinity or NaN
/// toInt` on `sf == 0` / `bw == 0`, which a half-initialised device does
/// report. This file keeps microseconds (2 packets x rounding is visible in
/// a 1-2 s figure) and forces callers through [areLoRaParamsValid].
///
/// The *flood cost* model in `calculateMessageTimeout()` `500 ms + 16 x
/// airtime` is a retry deadline, i.e. a deliberate upper bound on when a reply
/// could still arrive. It is not a wall-clock estimate and is not reused as one;
/// see [kImageSendChunkGapBase] for the pacing model this file uses instead.
///
/// Tsym = 2^SF / BW
/// lowDataRateOptimize (DE) = 1 if Tsym > 16 ms else 0
/// payloadSymbols = max(ceil((8*PL - 4*SF + 28 + 16*CRC - 20*IH)
/// / (4*(SF - 2*DE))) * (CR + 4), 0)
/// ToA = (preamble + 4.25) * Tsym + (8 + payloadSymbols) * Tsym
///
/// [codingRate] is given in the 5..8 domain (5 = 4/5 8 = 4/8), matching
/// [LoRaCodingRate.value]. If you hold a raw firmware coding rate, normalise it
/// with [normalizeCodingRate] first.
///
/// The result is returned at microsecond precision; no rounding to whole
/// milliseconds is applied.
Duration loraTimeOnAir({
required int payloadBytes,
required int spreadingFactor,
required int bandwidthHz,
required int codingRate,
int preambleSymbols = 8,
bool crc = true,
bool explicitHeader = true,
}) {
assert(spreadingFactor >= 5 && spreadingFactor <= 12);
assert(bandwidthHz > 0);
assert(codingRate >= 5 && codingRate <= 8);
final pl = math.max(payloadBytes, 0);
final sf = spreadingFactor;
// Symbol time in milliseconds.
final tsymMs = (1 << sf) / (bandwidthHz / 1000.0);
// Low data rate optimisation is mandated when a symbol lasts over 16 ms.
final de = tsymMs > 16.0 ? 1 : 0;
final ih = explicitHeader ? 0 : 1;
final numerator = 8 * pl - 4 * sf + 28 + 16 * (crc ? 1 : 0) - 20 * ih;
final denominator = 4 * (sf - 2 * de);
final payloadSymbols =
math.max((numerator / denominator).ceil() * codingRate, 0);
final preambleMs = (preambleSymbols + 4.25) * tsymMs;
final payloadMs = (8 + payloadSymbols) * tsymMs;
return Duration(microseconds: ((preambleMs + payloadMs) * 1000).round());
}
/// Normalises a raw firmware coding rate into the 5..8 domain used by
/// [loraTimeOnAir] and [LoRaCodingRate.value]. Some firmwares report 1..4.
int normalizeCodingRate(int deviceCodingRate) =>
deviceCodingRate <= 4 ? deviceCodingRate + 4 : deviceCodingRate;
/// Whether [loraTimeOnAir] can safely be called with these parameters.
///
/// The radio values reaching us are raw bytes off the wire (`currentSf`,
/// `currentBwHz`, `currentCr`), so a disconnected or half-initialised device
/// can hand us zeroes. Those are not merely wrong, they are fatal: `sf == 0`
/// drives the `4 * (SF - 2*DE)` denominator to zero and the subsequent
/// `.ceil()` throws "Unsupported operation: Infinity or NaN toInt", and
/// `bandwidthHz == 0` makes the symbol time infinite. Asserts alone do not help
/// in release builds, so callers must gate on this.
///
/// [codingRate] is checked in the 5..8 domain; normalise first.
bool areLoRaParamsValid({
required int? spreadingFactor,
required int? bandwidthHz,
required int? codingRate,
}) {
if (spreadingFactor == null || bandwidthHz == null || codingRate == null) {
return false;
}
if (spreadingFactor < 5 || spreadingFactor > 12) return false;
if (bandwidthHz <= 0) return false;
if (codingRate < 5 || codingRate > 8) return false;
return true;
}
/// Fixed part of the gap the sender must leave between two chunk packets.
///
/// Raw airtime is only the time our own transmitter is modulating. A multi-chunk
/// image is paced: the app hands chunk *i+1* to the companion radio only after
/// chunk *i* has been queued, transmitted and the local channel has cleared, so
/// wall clock is substantially longer than the sum of the airtimes. Two numbers
/// in this repository bound that gap:
///
/// * `connector/meshcore_protocol.dart:calculateMessageTimeout()` uses a
/// **500 ms base delay** for "the companion radio has dealt with this
/// packet", independent of airtime. That is where this constant comes from.
/// * MeshCore repeaters default to a flood retransmit spacing of **0.5 x
/// airtime** (see `repeater_txDelayHelper`), and a packet is retransmitted by
/// every repeater in range, so at least one full airtime of quiet is needed
/// before the next chunk to avoid colliding with the first hop. That is
/// [kImageSendChunkGapAirtimeFactor].
///
/// This is a MODEL, not a measurement: confirming it needs real radios, which is
/// out of scope here. It is deliberately derived from numbers already shipped in
/// this app rather than invented, and it is applied only *between* packets, so a
/// single-chunk send shows raw airtime unchanged.
const Duration kImageSendChunkGapBase = Duration(milliseconds: 500);
/// Airtime-proportional part of the inter-chunk gap. See
/// [kImageSendChunkGapBase].
const double kImageSendChunkGapAirtimeFactor = 1.0;
/// The pacing gap left after transmitting a packet whose airtime is
/// [packetAirtime], before the next chunk is queued.
Duration imageSendChunkGap(Duration packetAirtime) => Duration(
microseconds: kImageSendChunkGapBase.inMicroseconds +
(packetAirtime.inMicroseconds * kImageSendChunkGapAirtimeFactor)
.round(),
);
/// Result of estimating an image (or any chunked payload) send.
///
/// [perPacketAirtime] and [totalAirtime] are null when the radio parameters are
/// unknown in that case the packet count is still meaningful and should be
/// shown, but no airtime may be fabricated.
class SendEstimate {
/// Number of packets that will be transmitted, including the parity packet
/// when [includesParity] is true.
final int chunkCount;
/// Total on-air bytes across all packets (chunk headers + payload + any
/// configured transport overhead), including the parity packet.
final int totalBytes;
/// Airtime of one maximally-filled chunk packet, or null if the radio
/// parameters are unknown.
final Duration? perPacketAirtime;
/// Sum of the airtime of every packet actually sent, or null if the radio
/// parameters are unknown.
///
/// This is transmitter occupancy only. For anything the user is asked to wait
/// for, show [pacedWallClock] instead.
final Duration? totalAirtime;
/// Realistic wall clock for the whole send: [totalAirtime] plus the
/// inter-chunk pacing gap ([imageSendChunkGap]) after every packet but the
/// last. Null exactly when [totalAirtime] is null.
///
/// Equal to [totalAirtime] for a single-packet send.
final Duration? pacedWallClock;
/// Whether a XOR parity packet is included in [chunkCount] / [totalBytes].
final bool includesParity;
const SendEstimate({
required this.chunkCount,
required this.totalBytes,
required this.perPacketAirtime,
required this.totalAirtime,
required this.pacedWallClock,
required this.includesParity,
});
/// True when airtime could be computed (radio parameters were known).
bool get hasAirtime => totalAirtime != null;
@override
String toString() =>
'SendEstimate(chunks: $chunkCount, bytes: $totalBytes, '
'perPacket: $perPacketAirtime, total: $totalAirtime, '
'wallClock: $pacedWallClock, parity: $includesParity)';
@override
bool operator ==(Object other) =>
other is SendEstimate &&
other.chunkCount == chunkCount &&
other.totalBytes == totalBytes &&
other.perPacketAirtime == perPacketAirtime &&
other.totalAirtime == totalAirtime &&
other.pacedWallClock == pacedWallClock &&
other.includesParity == includesParity;
@override
int get hashCode => Object.hash(
chunkCount,
totalBytes,
perPacketAirtime,
totalAirtime,
pacedWallClock,
includesParity,
);
}
/// Number of data chunks needed for [payloadBytes], excluding parity.
///
/// Delegates to the transport so the estimate shown in the preview can never
/// disagree with what the chunker actually emits. Differs from
/// [imageDataChunkCount] in one respect only: a zero-byte payload needs zero
/// chunks here (there is nothing to estimate), whereas the transport still
/// emits one chunk so a receiver can observe an empty image.
int imageChunkCount(int payloadBytes) {
if (payloadBytes <= 0) return 0;
return imageDataChunkCount(payloadBytes);
}
/// Payload bytes carried by each data chunk, in order.
List<int> imageChunkPayloadSizes(int payloadBytes) {
final count = imageChunkCount(payloadBytes);
if (count == 0) return const [];
final sizes = <int>[];
var remaining = payloadBytes;
for (var i = 0; i < count; i++) {
final capacity =
i == 0 ? kImageChunkFirstCapacity : kImageChunkCapacity;
final take = remaining < capacity ? remaining : capacity;
sizes.add(take);
remaining -= take;
}
return sizes;
}
/// Estimates packet count and airtime for sending [payloadBytes] of chunked
/// image data.
///
/// [radio] may be null (radio settings not yet read from the device). In that
/// case the packet count and byte totals are still returned but both airtime
/// fields are null no default SF/BW is substituted, because a fabricated ETA
/// is worse than none for a feature whose purpose is informed consent.
///
/// [parity] adds exactly one XOR parity packet (GRP_DATA is unacknowledged, so
/// parity allows recovery of a single lost chunk). No parity packet is added
/// for an empty payload.
SendEstimate estimateSend({
required int payloadBytes,
required RadioSettings? radio,
bool parity = true,
int onAirOverheadBytes = kMeshCoreOnAirOverheadBytes,
}) {
return estimateSendFromRadioParams(
payloadBytes: payloadBytes,
spreadingFactor: radio?.spreadingFactor.value,
bandwidthHz: radio?.bandwidth.hz,
codingRate: radio?.codingRate.value,
parity: parity,
onAirOverheadBytes: onAirOverheadBytes,
);
}
/// Same as [estimateSend] but takes the raw, individually-nullable radio
/// parameters exposed by the connector (`currentSf`, `currentBwHz`,
/// `currentCr`). [codingRate] may be in either the 1..4 or 5..8 firmware
/// encoding; it is normalised via [normalizeCodingRate].
SendEstimate estimateSendFromRadioParams({
required int payloadBytes,
required int? spreadingFactor,
required int? bandwidthHz,
required int? codingRate,
bool parity = true,
int onAirOverheadBytes = kMeshCoreOnAirOverheadBytes,
}) {
final payload = math.max(payloadBytes, 0);
final sizes = imageChunkPayloadSizes(payload);
final dataChunks = sizes.length;
final withParity = parity && dataChunks > 0;
// On-air bytes per packet: chunk header + payload (+ chunk 0 metadata)
// + any transport overhead.
// On-air blob layout, taken from buildImageChunks() rather than assumed:
// data chunk : header + body (chunk 0's body opens with metadata)
// parity : header + len byte + a FULL kImageChunkBodyBytes XOR body
// The parity-length byte belongs to the PARITY chunk only charging it to
// every data chunk, and sizing parity from the largest data body, understated
// a 110-byte payload as 232 on-air bytes when the real total is 278.
final packetBytes = <int>[];
for (var i = 0; i < sizes.length; i++) {
final meta = i == 0 ? kImageChunkZeroMetadataBytes : 0;
packetBytes.add(
kImageChunkHeaderBytes + meta + sizes[i] + onAirOverheadBytes,
);
}
if (withParity) {
// Always the full blob: the XOR body is zero-padded to kImageChunkBodyBytes
// regardless of how short the data chunks are.
packetBytes.add(kImageChunkBlobBytes + onAirOverheadBytes);
}
final totalBytes = packetBytes.fold<int>(0, (a, b) => a + b);
final chunkCount = packetBytes.length;
// Only non-null AND in-range parameters are safe: see [areLoRaParamsValid].
// Anything else yields an estimate with packet counts but no airtime, which
// the UI must render as "unknown" rather than fabricating a number.
final cr = codingRate == null ? null : normalizeCodingRate(codingRate);
final known = areLoRaParamsValid(
spreadingFactor: spreadingFactor,
bandwidthHz: bandwidthHz,
codingRate: cr,
);
if (!known) {
return SendEstimate(
chunkCount: chunkCount,
totalBytes: totalBytes,
perPacketAirtime: null,
totalAirtime: null,
pacedWallClock: null,
includesParity: withParity,
);
}
// Safe to force: areLoRaParamsValid() above proved all three are non-null
// and in range.
Duration airtimeFor(int bytes) => loraTimeOnAir(
payloadBytes: bytes,
spreadingFactor: spreadingFactor!,
bandwidthHz: bandwidthHz!,
codingRate: cr!,
);
// Airtime of a maximally-filled chunk packet, i.e. the cost of one "typical"
// packet in the stream. A full blob is the whole kImageChunkBlobBytes.
final perPacket = airtimeFor(kImageChunkBlobBytes + onAirOverheadBytes);
var totalMicros = 0;
// Wall clock adds a pacing gap after every packet except the last, so a
// single-packet send is unaffected. See [kImageSendChunkGapBase].
var wallClockMicros = 0;
for (var i = 0; i < packetBytes.length; i++) {
final airtime = airtimeFor(packetBytes[i]);
totalMicros += airtime.inMicroseconds;
wallClockMicros += airtime.inMicroseconds;
if (i != packetBytes.length - 1) {
wallClockMicros += imageSendChunkGap(airtime).inMicroseconds;
}
}
return SendEstimate(
chunkCount: chunkCount,
totalBytes: totalBytes,
perPacketAirtime: perPacket,
totalAirtime: Duration(microseconds: totalMicros),
pacedWallClock: Duration(microseconds: wallClockMicros),
includesParity: withParity,
);
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class ImageSendButton extends StatelessWidget {
final bool enabled;
final VoidCallback onPressed;
final String? tooltip;
const ImageSendButton({
super.key,
required this.enabled,
required this.onPressed,
this.tooltip,
});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(enabled ? Icons.image : Icons.image_outlined),
onPressed: enabled ? onPressed : null,
tooltip: tooltip ?? context.l10n.chat_sendImage,
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import 'dart:typed_data';
/// Minimal, dependency-free abstraction over the neural image codec used by
/// [ImageSendPreviewSheet].
///
/// The real implementation lives in `lib/services/image_codec_service.dart`
/// (`ImageCodecService implements ImageSendCodec`) and
/// `lib/services/image_chunk_transport.dart`. This file deliberately does NOT
/// import them, so the preview sheet can be built, analyzed and previewed on
/// its own and so the service depends on the UI contract rather than the other
/// way round.
///
// Packet-count and airtime maths deliberately live in
// `lib/utils/lora_airtime.dart`, not here.
/// Hard minimum resolution for the codec. Images are centre-cropped to this
/// square before encoding; the decoder collapses below it.
const int kImageCodecSquareSize = 512;
/// Availability of the image codec, as far as the UI is concerned.
enum ImageCodecAvailability {
/// The feature is switched off in app settings.
disabled,
/// The codec model is being fetched; the feature is temporarily unusable.
downloading,
/// The codec is usable right now.
ready,
/// The codec cannot run on this device/platform at all.
unavailable,
}
/// Rate points the wire format can express.
///
/// The shipping build encodes at [standard] (`ft32`) only see
/// [kImageSendRatePoint]. The enum keeps more than one value because the rate
/// point is written into the chunk-0 metadata byte by
/// `image_chunk_transport.dart`, so the ordinals are part of the wire format and
/// must stay stable even for rate points the UI never offers. Removing [high]
/// would renumber nothing today but would make a future rate point silently
/// reuse ordinal 1 and be decoded as ft16 by older builds.
enum ImageCodecRatePoint {
/// `ft32` the only rate point this build sends. Measured 110-209 B
/// (mean 156 B) over 26 images, i.e. 1-2 data chunks plus parity.
standard,
/// `ft16` larger, higher fidelity. **Not offered in the UI**: the model
/// registry ships ft32 weights only. Retained so the ordinal stays reserved
/// and so bitstreams produced elsewhere can still be identified on the wire.
high,
}
/// The single rate point the compose UI encodes at.
///
/// The quality selector was removed once ft32 became the only shipping model;
/// every send goes out at this rate. Named rather than inlined so a future
/// second rate point has one place to come back to.
const ImageCodecRatePoint kImageSendRatePoint = ImageCodecRatePoint.standard;
/// Measured payload statistics for a rate point, in bytes.
///
/// These are real measurements over 26 images of rANS bitstreams produced by
/// the codec at 512x512, not estimates. They let the sheet show a plausible
/// range instantly, before an encode has finished.
class ImageCodecRateStats {
final int meanBytes;
final int minBytes;
final int maxBytes;
const ImageCodecRateStats({
required this.meanBytes,
required this.minBytes,
required this.maxBytes,
});
static const ImageCodecRateStats standard = ImageCodecRateStats(
meanBytes: 156,
minBytes: 110,
maxBytes: 209,
);
/// Retained for [ImageCodecRatePoint.high], which the UI no longer offers.
static const ImageCodecRateStats high = ImageCodecRateStats(
meanBytes: 288,
minBytes: 176,
maxBytes: 409,
);
static ImageCodecRateStats forRate(ImageCodecRatePoint rate) {
switch (rate) {
case ImageCodecRatePoint.standard:
return standard;
case ImageCodecRatePoint.high:
return high;
}
}
}
/// The encode interface the preview sheet consumes.
abstract class ImageSendCodec {
/// Current availability. The sheet renders a non-interactive explanation for
/// anything other than [ImageCodecAvailability.ready].
ImageCodecAvailability get availability;
/// Why [availability] is [ImageCodecAvailability.unavailable], as one
/// user-facing sentence, or null when the codec is not permanently
/// unavailable.
///
/// This is the sentence the compose sheet shows in place of its generic
/// "not available on this device" string: `unavailable` is a permanent
/// property of the build (no native runtime, no entropy path, backend failed
/// to load) and the only useful thing the UI can do is say WHICH one it hit.
/// Implementations must not return an empty string; return null instead.
String? get unavailableReason;
/// Encode [imageBytes] (any common still format) at [rate], returning the
/// compressed bitstream that will be chunked onto the air.
///
/// Implementations are expected to centre-crop to
/// [kImageCodecSquareSize] square first.
Future<Uint8List> encode(Uint8List imageBytes, ImageCodecRatePoint rate);
}
/// A deterministic stand-in used for widget previews and tests.
///
/// It produces a buffer of the measured mean size for the requested rate point
/// so the sheet can be exercised end-to-end without the real codec.
class FakeImageSendCodec implements ImageSendCodec {
@override
final ImageCodecAvailability availability;
/// Mirrors `ImageCodecService.unavailableReason`; null unless a test wants to
/// exercise the "why can't I send?" path.
@override
final String? unavailableReason;
final Duration latency;
const FakeImageSendCodec({
this.availability = ImageCodecAvailability.ready,
this.unavailableReason,
this.latency = const Duration(milliseconds: 250),
});
@override
Future<Uint8List> encode(
Uint8List imageBytes,
ImageCodecRatePoint rate,
) async {
if (latency > Duration.zero) {
await Future<void>.delayed(latency);
}
final size = ImageCodecRateStats.forRate(rate).meanBytes;
return Uint8List.fromList(
List<int>.generate(size, (i) => (i * 31 + rate.index) & 0xFF),
);
}
}
+782
View File
@@ -0,0 +1,782 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../utils/lora_airtime.dart';
import 'image_send_codec_binding.dart';
/// Estimated wall clock above which the sheet warns the user explicitly.
const Duration kImageSendLongAirtime = Duration(seconds: 5);
/// What the user committed to when they tapped Send.
class ImageSendPreviewResult {
/// The encoded bitstream, ready to be chunked onto the air.
final Uint8List payload;
/// The rate point the payload was encoded at. Always [kImageSendRatePoint] in
/// this build; carried through because the transport writes it on the wire.
final ImageCodecRatePoint rate;
/// Whether an XOR parity packet should be appended.
final bool includeParity;
/// Packets that will actually be transmitted, parity included.
final int packetCount;
/// Estimated transmitter occupancy, or null if the radio settings were
/// unknown. This is *not* how long the send takes see [wallClock].
final Duration? airtime;
/// Estimated wall clock including per-chunk pacing the figure the user was
/// actually shown. Null when the radio settings were unknown.
final Duration? wallClock;
const ImageSendPreviewResult({
required this.payload,
required this.rate,
required this.includeParity,
required this.packetCount,
required this.airtime,
required this.wallClock,
});
}
/// Shows the pre-send image preview.
///
/// Returns null if the user cancelled, or an [ImageSendPreviewResult] carrying
/// the encoded payload if they confirmed. The caller owns the actual send.
///
/// [imageBytes] is the raw picked file (JPEG/PNG/). [originalFileBytes] is its
/// on-disk size, shown against the transmitted size.
///
/// [radio] overrides the live radio parameters; when omitted they are read from
/// the [MeshCoreConnector] in the widget tree. Pass it explicitly to preview
/// this sheet without a connector.
///
/// There is no quality/rate argument: ft32 ([kImageSendRatePoint]) is the only
/// shipping model, so the sheet no longer offers a choice.
Future<ImageSendPreviewResult?> showImageSendPreviewSheet({
required BuildContext context,
required Uint8List imageBytes,
required int originalFileBytes,
required ImageSendCodec codec,
ImageSendRadio? radio,
bool initialParity = true,
}) {
return showModalBottomSheet<ImageSendPreviewResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => ImageSendPreviewSheet(
imageBytes: imageBytes,
originalFileBytes: originalFileBytes,
codec: codec,
radio: radio,
initialParity: initialParity,
),
);
}
class ImageSendPreviewSheet extends StatefulWidget {
final Uint8List imageBytes;
final int originalFileBytes;
final ImageSendCodec codec;
final ImageSendRadio? radio;
final bool initialParity;
const ImageSendPreviewSheet({
super.key,
required this.imageBytes,
required this.originalFileBytes,
required this.codec,
this.radio,
this.initialParity = true,
});
@override
State<ImageSendPreviewSheet> createState() => _ImageSendPreviewSheetState();
}
/// The live radio parameters, exactly as the connector exposes them: each may
/// be null before the device has reported its SELF_INFO frame.
class ImageSendRadio {
final int? spreadingFactor;
final int? bandwidthHz;
/// Raw firmware coding rate; [estimateSendFromRadioParams] normalises it.
final int? rawCodingRate;
const ImageSendRadio({
this.spreadingFactor,
this.bandwidthHz,
this.rawCodingRate,
});
bool get isKnown =>
spreadingFactor != null && bandwidthHz != null && rawCodingRate != null;
}
/// Packet/byte/airtime figures for one rate point.
///
/// Before an encode has finished the payload size is only known as a measured
/// range, so [best] and [worst] differ; afterwards they are identical.
class _RateEstimate {
/// Exact payload size once encoded, null while still estimating.
final int? payloadBytes;
final SendEstimate best;
final SendEstimate worst;
const _RateEstimate({
required this.payloadBytes,
required this.best,
required this.worst,
});
bool get isExact => payloadBytes != null;
}
class _ImageSendPreviewSheetState extends State<ImageSendPreviewSheet> {
/// The encoded ft32 bitstream, once the encode finishes.
Uint8List? _encoded;
late bool _parity;
bool _encoding = false;
bool _sending = false;
bool _failed = false;
/// Latest radio params seen in build; used by [_onSend] so the confirm path
/// does not have to touch an InheritedWidget after an await.
ImageSendRadio? _radio;
@override
void initState() {
super.initState();
_parity = widget.initialParity;
if (widget.codec.availability == ImageCodecAvailability.ready) {
_encode();
}
}
Future<Uint8List?> _encode() async {
final cached = _encoded;
if (cached != null) return cached;
setState(() {
_encoding = true;
_failed = false;
});
try {
final bytes =
await widget.codec.encode(widget.imageBytes, kImageSendRatePoint);
if (!mounted) return bytes;
setState(() {
_encoded = bytes;
_encoding = false;
});
return bytes;
} catch (_) {
if (!mounted) return null;
setState(() {
_encoding = false;
_failed = true;
});
return null;
}
}
ImageSendRadio _radioParams(BuildContext context) {
final override = widget.radio;
if (override != null) return override;
final connector = context.watch<MeshCoreConnector>();
return ImageSendRadio(
spreadingFactor: connector.currentSf,
bandwidthHz: connector.currentBwHz,
rawCodingRate: connector.currentCr,
);
}
_RateEstimate _estimateFor(ImageSendRadio radio) {
final bytes = _encoded;
if (bytes != null) {
final exact = _estimate(bytes.length, radio);
return _RateEstimate(
payloadBytes: bytes.length,
best: exact,
worst: exact,
);
}
// Nothing encoded yet: show the measured ft32 range so the user sees a
// figure immediately rather than a spinner.
final stats = ImageCodecRateStats.forRate(kImageSendRatePoint);
return _RateEstimate(
payloadBytes: null,
best: _estimate(stats.minBytes, radio),
worst: _estimate(stats.maxBytes, radio),
);
}
SendEstimate _estimate(int payloadBytes, ImageSendRadio radio) {
return estimateSendFromRadioParams(
payloadBytes: payloadBytes,
spreadingFactor: radio.spreadingFactor,
bandwidthHz: radio.bandwidthHz,
codingRate: radio.rawCodingRate,
parity: _parity,
);
}
Future<void> _onSend() async {
setState(() => _sending = true);
final payload = await _encode();
if (!mounted) return;
if (payload == null) {
setState(() => _sending = false);
return;
}
final radio = _radio ?? const ImageSendRadio();
final estimate = _estimate(payload.length, radio);
Navigator.of(context).pop(
ImageSendPreviewResult(
payload: payload,
rate: kImageSendRatePoint,
includeParity: estimate.includesParity,
packetCount: estimate.chunkCount,
airtime: estimate.totalAirtime,
wallClock: estimate.pacedWallClock,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final l10n = context.l10n;
final radio = _radioParams(context);
_radio = radio;
final selected = _estimateFor(radio);
final ready = widget.codec.availability == ImageCodecAvailability.ready;
return SafeArea(
top: false,
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_grabHandle(colors),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
l10n.imageSend_title,
style: theme.textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: l10n.imageSend_cancel,
onPressed: _sending
? null
: () => Navigator.of(context).maybePop(),
),
],
),
),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
_preview(theme, colors),
const SizedBox(height: 16),
if (!ready) _unavailableBanner(theme, colors),
if (ready) ...[
// Packet count and time on air lead: with the quality
// selector gone they are the only decision the user makes.
_headlineStats(theme, colors, selected),
const SizedBox(height: 16),
_sizeRow(theme, colors, selected),
const SizedBox(height: 12),
// Warnings sit directly under the figures they qualify.
..._warnings(theme, colors, radio, selected),
const SizedBox(height: 4),
_parityTile(theme, colors),
if (_failed) ...[
const SizedBox(height: 12),
_banner(
colors: colors,
theme: theme,
icon: Icons.error_outline,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
title: l10n.imageSend_encodeFailed,
body: null,
),
],
],
],
),
),
_actions(theme, ready),
],
),
),
);
}
Widget _grabHandle(ColorScheme colors) => Container(
width: 36,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: colors.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
);
/// The square render. [BoxFit.fill] on a 1:1 box is exactly the 512x512
/// centre crop the codec will take, so what is shown is what is sent.
///
/// The preview is deliberately capped in height: the packet count and airtime
/// below it are the reason this screen exists and must not be pushed off the
/// first screenful by a full-width square.
Widget _preview(ThemeData theme, ColorScheme colors) {
final maxSide = MediaQuery.sizeOf(context).height * 0.28;
return Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxSide, maxWidth: maxSide),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 1,
child: Container(
color: colors.surfaceContainerHighest,
child: Image.memory(
widget.imageBytes,
fit: BoxFit.fill,
alignment: Alignment.center,
gaplessPlayback: true,
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
size: 48,
color: colors.onSurfaceVariant,
),
),
),
),
),
),
),
const SizedBox(height: 8),
Text(
context.l10n.imageSend_cropNote,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
);
}
Widget _sizeRow(
ThemeData theme,
ColorScheme colors,
_RateEstimate estimate,
) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_sizeChip(
theme,
colors,
context.l10n.imageSend_originalSize,
_formatBytes(widget.originalFileBytes),
strikethrough: true,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward, size: 18, color: colors.onSurfaceVariant),
),
_sizeChip(
theme,
colors,
context.l10n.imageSend_onAirSize,
_onAirBytesText(estimate),
emphasised: true,
),
],
);
}
Widget _sizeChip(
ThemeData theme,
ColorScheme colors,
String label,
String value, {
bool strikethrough = false,
bool emphasised = false,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
color: emphasised ? colors.primary : colors.onSurface,
decoration: strikethrough ? TextDecoration.lineThrough : null,
),
),
],
);
}
/// The point of the whole screen: what this send costs, in big type.
///
/// Only two figures, both large: the packet count and the realistic time the
/// send will take. The byte sizes moved down to [_sizeRow] with the quality
/// selector gone there is room to give these two the whole width.
Widget _headlineStats(
ThemeData theme,
ColorScheme colors,
_RateEstimate estimate,
) {
final localizations = context.l10n;
return Container(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: _stat(
theme,
colors,
localizations.imageSend_packetsLabel,
_packetCountText(estimate),
),
),
_statDivider(colors),
Expanded(
child: _stat(
theme,
colors,
localizations.imageSend_airtimeLabel,
_airtimeText(estimate),
emphasised: true,
),
),
],
),
);
}
Widget _statDivider(ColorScheme colors) => Container(
width: 1,
height: 44,
color: colors.outlineVariant,
);
Widget _stat(
ThemeData theme,
ColorScheme colors,
String label,
String value, {
bool emphasised = false,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
color: emphasised ? colors.primary : colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
);
}
Widget _parityTile(ThemeData theme, ColorScheme colors) {
final localizations = context.l10n;
return SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _parity,
onChanged: _sending ? null : (v) => setState(() => _parity = v),
secondary: const Icon(Icons.shield_outlined, size: 20),
title: Text(localizations.imageSend_parityTitle),
subtitle: Text(
localizations.imageSend_paritySubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
);
}
List<Widget> _warnings(
ThemeData theme,
ColorScheme colors,
ImageSendRadio radio,
_RateEstimate estimate,
) {
final localizations = context.l10n;
final widgets = <Widget>[];
if (!radio.isKnown) {
widgets.add(
_banner(
colors: colors,
theme: theme,
icon: Icons.settings_input_antenna,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
title: localizations.imageSend_radioUnknownTitle,
body: localizations.imageSend_radioUnknownBody,
),
);
} else {
// Compared against the paced wall clock, not raw airtime: the warning is
// about how long the user waits, which is the figure shown above it.
final worst = estimate.worst.pacedWallClock;
if (worst != null && worst >= kImageSendLongAirtime) {
widgets.add(
_banner(
colors: colors,
theme: theme,
icon: Icons.hourglass_bottom,
background: colors.tertiaryContainer,
foreground: colors.onTertiaryContainer,
title: localizations.imageSend_longSendTitle,
body: localizations.imageSend_longSendBody(
_formatDuration(worst),
),
),
);
}
widgets.add(const SizedBox(height: 8));
widgets.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.hub_outlined, size: 16, color: colors.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
localizations.imageSend_floodNote,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
],
),
);
}
return widgets;
}
/// The "why can't I send?" banner.
///
/// For [ImageCodecAvailability.unavailable] the generic localised string says
/// only that the codec cannot run, which leaves the user with no idea whether
/// to wait, download something, or give up. The codec knows exactly which
/// permanent property of the build it hit, so
/// [ImageSendCodec.unavailableReason] REPLACES the generic string whenever it
/// is non-empty. It falls back to the localised generic only when the codec
/// declined to say why.
Widget _unavailableBanner(ThemeData theme, ColorScheme colors) {
final localizations = context.l10n;
final String message;
switch (widget.codec.availability) {
case ImageCodecAvailability.disabled:
message = localizations.imageSend_codecDisabled;
break;
case ImageCodecAvailability.downloading:
message = localizations.imageSend_codecDownloading;
break;
case ImageCodecAvailability.unavailable:
final reason = widget.codec.unavailableReason?.trim();
message = (reason == null || reason.isEmpty)
? localizations.imageSend_codecUnavailable
: reason;
break;
case ImageCodecAvailability.ready:
return const SizedBox.shrink();
}
return _banner(
colors: colors,
theme: theme,
icon: Icons.info_outline,
background: colors.surfaceContainerHighest,
foreground: colors.onSurfaceVariant,
title: message,
body: null,
);
}
Widget _banner({
required ColorScheme colors,
required ThemeData theme,
required IconData icon,
required Color background,
required Color foreground,
required String title,
required String? body,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: foreground),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: foreground,
),
),
if (body != null) ...[
const SizedBox(height: 2),
Text(
body,
style: theme.textTheme.bodySmall?.copyWith(
color: foreground,
),
),
],
],
),
),
],
),
);
}
Widget _actions(ThemeData theme, bool ready) {
final localizations = context.l10n;
final busy = _sending || _encoding;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed:
_sending ? null : () => Navigator.of(context).maybePop(),
child: Text(localizations.imageSend_cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: (!ready || busy) ? null : _onSend,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(localizations.imageSend_send),
),
),
],
),
);
}
/// Total bytes actually put on the air (headers + payload + parity).
String _onAirBytesText(_RateEstimate estimate) {
final lo = estimate.best.totalBytes;
final hi = estimate.worst.totalBytes;
if (lo == hi) return _formatBytes(lo);
return _rangeText(_formatBytes(lo), _formatBytes(hi));
}
/// Bare packet count, e.g. "3" or "23".
String _packetCountText(_RateEstimate estimate) {
final lo = estimate.best.chunkCount;
final hi = estimate.worst.chunkCount;
if (lo == hi) return '$lo';
return _rangeText('$lo', '$hi');
}
/// The headline time figure.
///
/// This is [SendEstimate.pacedWallClock], NOT raw airtime: a 2-3 packet image
/// is paced, so raw transmitter occupancy would understate the wait the user
/// is being asked to accept. Renders `imageSend_unknownValue`
/// ("") whenever the radio parameters are unknown areLoRaParamsValid() has
/// already forced the estimate's time fields to null in that case, and a
/// fabricated ETA is worse than none on a screen whose whole purpose is
/// informed consent.
String _airtimeText(_RateEstimate estimate) {
final l10n = context.l10n;
final min = estimate.best.pacedWallClock;
final max = estimate.worst.pacedWallClock;
if (min == null || max == null) return l10n.imageSend_unknownValue;
if (min == max) return _formatDuration(max);
return _rangeText(_formatDuration(min), _formatDuration(max));
}
String _rangeText(String min, String max) =>
context.l10n.imageSend_range(min, max);
String _formatDuration(Duration d) {
final l10n = context.l10n;
final totalSeconds = d.inMilliseconds / 1000.0;
if (totalSeconds < 60) {
final text = totalSeconds < 10
? totalSeconds.toStringAsFixed(1)
: totalSeconds.round().toString();
return l10n.imageSend_secondsValue(text);
}
final minutes = d.inMinutes;
final seconds = d.inSeconds - minutes * 60;
return l10n.imageSend_minutesSecondsValue('$minutes', '$seconds');
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} kB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
+652
View File
@@ -0,0 +1,652 @@
/// In-bubble renderer for AEIC images received over the mesh.
///
/// Mirrors `lib/widgets/gif_message.dart`: a fixed-size box (so the reversed
/// `ListView` never jumps as chunks land), one widget per message, all state
/// pulled from [ReceivedImageStore].
///
/// ## RISK R6 permanent synthesized-content label
///
/// A 156-byte AEIC bitstream decoded by a one-step diffusion model is not a
/// photograph. On the measurement corpus this decoder turned a fox into a
/// sharp, natural, artefact-free sheep. Every decoded image therefore carries
/// TWO provenance elements that no caller can influence:
///
/// * [_SyntheticBanner] a band burnt into the bottom of the image itself,
/// inside the `ClipRRect`, inside the `Stack`. It is
/// part of the pixels the user sees and part of any
/// screenshot that contains the image at all.
/// * [_SyntheticCaption] a line of text immediately under the image.
///
/// Both are private to this file, are not parameterised, and are reused
/// verbatim by the full-screen viewer this widget opens on tap, so there is no
/// route in the app that can show the pixels without them. The only case in
/// which they are absent is [ReceivedImageEntry.isOutgoing], where the PNG is
/// the sender's own 512x512 crop and the label would be a falsehood — that is
/// read from the store's sidecar, never from a constructor argument.
///
/// The widget deliberately exposes no share/save action: an exported raster
/// would have to have the caption composited into it first, and that belongs
/// with whoever implements export, not here.
library;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/received_image_store.dart';
import 'image_send_codec_binding.dart' show ImageCodecAvailability;
/// User-visible strings for the NON-decoded states.
///
/// Exists so the wiring agent can hand in `AppLocalizations` values once the
/// `receivedImage_*` ARB keys land (they do not exist yet, so English defaults
/// ship inline). Deliberately contains no field for the R6 badge or caption:
/// those must not be overridable, shortenable or blankable.
@immutable
class ReceivedImageStrings {
final String Function(int received, int total) incoming;
final String queued;
final String tapToDecode;
/// Headline of the awaiting-action placeholder: how big the bitstream is and
/// how many packets carried it, e.g. "156 bytes · 2 packets".
final String Function(int bytes, int packets) awaiting;
/// The affordance on the awaiting-action placeholder.
final String tapToProcess;
final String decoding;
final String Function(int received, int total) incomplete;
final String corrupt;
final String decoderMissing;
final String evicted;
final String retry;
final String decodeAgain;
final String openSettings;
const ReceivedImageStrings({
required this.incoming,
required this.queued,
required this.tapToDecode,
// Optional on purpose: `channel_chat_screen.dart` builds this object and is
// owned by another workstream, so the two tap-to-process strings must not
// break that call site before the `receivedImage_*` ARB keys land.
this.awaiting = _defaultAwaiting,
this.tapToProcess = 'Tap to process',
required this.decoding,
required this.incomplete,
required this.corrupt,
required this.decoderMissing,
required this.evicted,
required this.retry,
required this.decodeAgain,
required this.openSettings,
});
// TODO(l10n): replace with context.l10n.receivedImage_* once app_en.arb has
// the keys listed in section 5(k) of the receive spec.
static ReceivedImageStrings english = ReceivedImageStrings(
incoming: (received, total) => '$received of $total packets',
queued: 'Waiting to decode',
tapToDecode: 'Tap to decode',
awaiting: _defaultAwaiting,
tapToProcess: 'Tap to process',
decoding: 'Reconstructing… about 1 s',
incomplete: (received, total) =>
'Image incomplete — $received of $total packets arrived',
corrupt: 'Image could not be reconstructed',
decoderMissing: 'Image received — image decoding is off',
evicted: 'Image no longer stored',
retry: 'Try again',
decodeAgain: 'Decode again',
openSettings: 'Set up',
);
}
/// English default for [ReceivedImageStrings.awaiting]. A top-level function so
/// it can be a `const` default value on the constructor.
String _defaultAwaiting(int bytes, int packets) =>
'$bytes bytes · $packets packets';
/// Text of the R6 label. Private and const: not injectable, not localisable
/// yet, and never empty.
const String _kSyntheticBadge = 'AI-reconstructed';
/// The caption is built from the ACTUAL bitstream size, never a nominal one:
/// quoting a fixed "~156 bytes" under an image that took 209 is its own small
/// dishonesty, in a label whose whole job is honesty.
String _syntheticCaptionFor(int bytes) =>
'Reconstructed by an AI model from $bytes bytes. '
'Fine detail is generated, not transmitted.';
class ReceivedImageMessage extends StatefulWidget {
/// 14 hex chars, as parsed out of the message text by
/// [ReceivedImageRef.parse].
final String streamId;
/// True for the local user's own message. Not an R6 escape hatch: the label is
/// driven by [ReceivedImageEntry.synthesized], which is derived in the store.
final bool isOutgoing;
/// Colour for the explanatory text inside a chat bubble.
final Color fallbackTextColor;
final double maxSize;
/// Test/preview override. Normally resolved from the widget tree.
final ReceivedImageStore? store;
final ReceivedImageStrings? strings;
/// Route to the image-messages settings page (offered only when the decoder
/// is missing because no weights are downloaded).
final VoidCallback? onOpenCodecSettings;
const ReceivedImageMessage({
super.key,
required this.streamId,
required this.isOutgoing,
required this.fallbackTextColor,
this.maxSize = 200,
this.store,
this.strings,
this.onOpenCodecSettings,
});
@override
State<ReceivedImageMessage> createState() => _ReceivedImageMessageState();
}
class _ReceivedImageMessageState extends State<ReceivedImageMessage> {
ReceivedImageStore? _store;
/// Which stream we have already asked the store to page in. Keyed by id, not
/// a bare bool: a `ListView` recycles this State object across messages, and
/// a bool left over from the previous stream meant the new one's pixels were
/// never requested and the bubble sat on a spinner forever.
String? _pngRequestedFor;
ReceivedImageStore? _resolveStore() {
final injected = widget.store;
if (injected != null) return injected;
try {
return Provider.of<ReceivedImageStore>(context, listen: false);
} on ProviderNotFoundException {
// The receive service is not registered (e.g. a screen test); render the
// "no decoder" state rather than crashing the whole message list.
return null;
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_store = _resolveStore();
_maybeLoadPng();
}
@override
void didUpdateWidget(covariant ReceivedImageMessage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.streamId != widget.streamId) {
_pngRequestedFor = null;
_store = _resolveStore();
_maybeLoadPng();
}
}
void _maybeLoadPng() {
final store = _store;
if (store == null) return;
if (_pngRequestedFor == widget.streamId) return;
final entry = store.entryFor(widget.streamId);
if (entry == null) return;
if (entry.state != ReceivedImageState.decoded) return;
if (entry.pngBytes != null) return;
_pngRequestedFor = widget.streamId;
store.ensurePng(widget.streamId);
}
@override
Widget build(BuildContext context) {
final store = _store;
if (store == null) {
return _box(context, _unavailableBody(context, null));
}
return ValueListenableBuilder<ReceivedImageEntry?>(
valueListenable: store.listenableFor(widget.streamId),
builder: (context, _, _) {
final entry = store.entryFor(widget.streamId);
_maybeLoadPng();
if (entry == null) {
return _box(context, _unavailableBody(context, null));
}
return _buildForEntry(context, store, entry);
},
);
}
ReceivedImageStrings get _s => widget.strings ?? ReceivedImageStrings.english;
Widget _buildForEntry(
BuildContext context,
ReceivedImageStore store,
ReceivedImageEntry entry,
) {
switch (entry.state) {
case ReceivedImageState.decoded:
final png = entry.pngBytes;
if (png == null) {
return _box(
context,
_progressBody(
context,
progress: null,
label: _s.decoding,
),
);
}
return _decodedBody(context, entry, png);
case ReceivedImageState.receiving:
final total = entry.totalChunks <= 0 ? 1 : entry.totalChunks;
return _box(
context,
_progressBody(
context,
progress: (entry.receivedChunks / total).clamp(0.0, 1.0),
label: _s.incoming(entry.receivedChunks, total),
),
);
// "Bitstream complete, not decoded". Two shades of it, distinguished by
// queue membership rather than by a fourth enum value:
// needsManualDecode -> parked, waiting for a tap (the setting is off,
// or the burst cap trimmed it)
// !needsManualDecode -> already in the store's decode queue
// Both are tappable. The second one deliberately so: a sidecar restored
// by load() is never re-enqueued, so without a tap target a restored
// "Waiting to decode" card would be dead forever.
case ReceivedImageState.reassembled:
return _box(
context,
_iconBody(
context,
icon: entry.needsManualDecode
? Icons.image_outlined
: Icons.hourglass_empty,
label: entry.needsManualDecode
? _s.awaiting(entry.bitstreamByteCount, entry.totalChunks)
: _s.queued,
action: entry.needsManualDecode
? _ActionSpec(
_s.tapToProcess,
() => _onProcessTap(store, entry),
)
: null,
),
onTap: () => _onProcessTap(store, entry),
);
case ReceivedImageState.decoding:
return _box(
context,
_progressBody(context, progress: null, label: _s.decoding),
);
case ReceivedImageState.failedIncomplete:
return _box(
context,
_iconBody(
context,
icon: Icons.broken_image_outlined,
label: _s.incomplete(entry.receivedChunks, entry.totalChunks),
isError: true,
),
);
case ReceivedImageState.failedCorrupt:
return _box(
context,
_iconBody(
context,
icon: Icons.error_outline,
label: _s.corrupt,
isError: true,
action: entry.canRetryDecode
? _ActionSpec(_s.retry, () => store.requestDecode(entry.streamId))
: null,
),
);
case ReceivedImageState.decoderUnavailable:
return _box(context, _unavailableBody(context, entry));
case ReceivedImageState.evicted:
return _box(
context,
_iconBody(
context,
icon: Icons.delete_outline,
label: _s.evicted,
action: entry.canRetryDecode
? _ActionSpec(
_s.decodeAgain,
() => store.requestDecode(entry.streamId),
)
: null,
),
);
}
}
/// Availability of the store's decoder seam.
///
/// Read through the store's public `decoder` field rather than importing
/// `ImageCodecService`: the bubble only needs to know whether a tap would
/// reach a working codec.
ImageCodecAvailability _availability(ReceivedImageStore store) =>
store.decoder?.availability ?? ImageCodecAvailability.unavailable;
/// The one tap handler for the awaiting-action card.
///
/// With no model installed, going through [ReceivedImageStore.requestDecode]
/// would cost the user two taps: the entry would flip to
/// `decoderUnavailable`, and only *then* would the card offer a "Set up"
/// button. So when the codec is not ready and the host screen gave us a route
/// to the image-messages setting, go straight there.
void _onProcessTap(ReceivedImageStore store, ReceivedImageEntry entry) {
final openSettings = widget.onOpenCodecSettings;
if (_availability(store) != ImageCodecAvailability.ready &&
openSettings != null) {
openSettings();
return;
}
store.requestDecode(entry.streamId);
}
// ---- bodies --------------------------------------------------------------
/// 4:3 placeholder box, same discipline as `GifMessage`, so a bubble does not
/// resize as an image progresses.
Widget _box(BuildContext context, Widget child, {VoidCallback? onTap}) {
final theme = Theme.of(context);
final box = Container(
width: widget.maxSize,
height: widget.maxSize * 0.75,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
),
child: Center(child: child),
);
if (onTap == null) return box;
return GestureDetector(onTap: onTap, child: box);
}
Widget _progressBody(
BuildContext context, {
required double? progress,
required String label,
}) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2, value: progress),
),
const SizedBox(height: 8),
_label(context, label),
],
);
}
Widget _iconBody(
BuildContext context, {
required IconData icon,
required String label,
bool isError = false,
_ActionSpec? action,
}) {
final color = isError
? Theme.of(context).colorScheme.error
: widget.fallbackTextColor;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 22, color: color),
const SizedBox(height: 6),
_label(context, label, color: color),
if (action != null)
TextButton(
onPressed: action.onPressed,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(action.label, style: const TextStyle(fontSize: 12)),
),
],
);
}
Widget _unavailableBody(BuildContext context, ReceivedImageEntry? entry) {
final canSetUp = widget.onOpenCodecSettings != null;
return _iconBody(
context,
icon: Icons.visibility_off_outlined,
label: _s.decoderMissing,
action: canSetUp
? _ActionSpec(_s.openSettings, widget.onOpenCodecSettings!)
: (entry != null && entry.canRetryDecode && _store != null
? _ActionSpec(
_s.retry,
() => _store!.requestDecode(entry.streamId),
)
: null),
);
}
Widget _label(BuildContext context, String text, {Color? color}) {
return Text(
text,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: color ?? widget.fallbackTextColor),
);
}
// ---- decoded (R6) --------------------------------------------------------
Widget _decodedBody(
BuildContext context,
ReceivedImageEntry entry,
Uint8List png,
) {
final size = widget.maxSize;
return GestureDetector(
onTap: () => _openViewer(context, entry, png),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: size,
// The codec always produces a 512x512 square because the sender
// stretched the whole frame into it. entry.displayAspectRatio is
// the shape it started as, so undo the stretch here rather than
// showing a widened photo. Null means square or unknown.
height: entry.displayAspectRatio == null
? size
: size / entry.displayAspectRatio!,
child: Stack(
children: [
Positioned.fill(
// fill, not contain: the pixels ARE the stretched square,
// so mapping them onto the original aspect box is exactly
// the inverse of what the sender did.
child: Image.memory(png, fit: BoxFit.fill),
),
if (entry.synthesized)
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: _SyntheticBanner(),
),
],
),
),
),
if (entry.synthesized)
SizedBox(
width: size,
child: _SyntheticCaption(bytes: entry.bitstreamByteCount),
),
],
),
);
}
void _openViewer(
BuildContext context,
ReceivedImageEntry entry,
Uint8List png,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _ReceivedImageViewer(
png: png,
synthesized: entry.synthesized,
bytes: entry.bitstreamByteCount,
),
),
);
}
}
class _ActionSpec {
final String label;
final VoidCallback onPressed;
const _ActionSpec(this.label, this.onPressed);
}
/// R6, part 1: a band composited over the bottom of the image.
class _SyntheticBanner extends StatelessWidget {
const _SyntheticBanner();
@override
Widget build(BuildContext context) {
return Container(
height: 18,
alignment: Alignment.center,
color: Colors.black.withValues(alpha: 0.62),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_awesome, size: 11, color: Colors.white),
const SizedBox(width: 4),
Text(
_kSyntheticBadge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
],
),
);
}
}
/// R6, part 2: the full sentence, immediately under the image.
class _SyntheticCaption extends StatelessWidget {
/// Contrast variant only. The TEXT is the same const in both cases; there is
/// no parameter that can shorten, blank or restyle it away.
final bool onDark;
/// Size of the bitstream this image was reconstructed from.
final int bytes;
const _SyntheticCaption({required this.bytes, this.onDark = false});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_syntheticCaptionFor(bytes),
// No maxLines: the bubble is narrow and the sentence must never be
// truncated. A label that reads "Details are invented. Not a" is worse
// than none it clips exactly where the warning was going.
softWrap: true,
style: TextStyle(
fontSize: 10,
color: onDark
? Colors.white70
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
/// Full-screen view. Private to this file so no other widget can present the
/// pixels without the two R6 elements.
class _ReceivedImageViewer extends StatelessWidget {
final Uint8List png;
final bool synthesized;
/// Bitstream size, so the full-screen caption quotes the same real number as
/// the one in the transcript.
final int bytes;
const _ReceivedImageViewer({
required this.png,
required this.synthesized,
required this.bytes,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Stack(
children: [
InteractiveViewer(child: Image.memory(png)),
if (synthesized)
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: _SyntheticBanner(),
),
],
),
),
if (synthesized)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _SyntheticCaption(bytes: bytes, onDark: true),
),
],
),
),
);
}
}
@@ -6,9 +6,17 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_onnxruntime/flutter_onnxruntime_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
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_onnxruntime_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterOnnxruntimePlugin");
flutter_onnxruntime_plugin_register_with_registrar(flutter_onnxruntime_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+2
View File
@@ -3,6 +3,8 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_onnxruntime
url_launcher_linux url_launcher_linux
) )
@@ -6,8 +6,10 @@ import FlutterMacOS
import Foundation import Foundation
import cryptography_flutter import cryptography_flutter
import file_selector_macos
import flutter_blue_plus_darwin import flutter_blue_plus_darwin
import flutter_local_notifications import flutter_local_notifications
import flutter_onnxruntime
import mobile_scanner import mobile_scanner
import package_info_plus import package_info_plus
import share_plus import share_plus
@@ -17,8 +19,10 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin")) CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterOnnxruntimePlugin.register(with: registry.registrar(forPlugin: "FlutterOnnxruntimePlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
+1 -1
View File
@@ -1,4 +1,4 @@
platform :osx, '10.15' platform :osx, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+1 -1
View File
@@ -17,6 +17,6 @@ SPEC CHECKSUMS:
flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6 flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
+3 -3
View File
@@ -567,7 +567,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
@@ -649,7 +649,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -699,7 +699,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "onnxruntime-swift-package-manager",
"kind" : "remoteSourceControl",
"location" : "https://github.com/masicai/onnxruntime-swift-package-manager",
"state" : {
"revision" : "57f28b1fdd6fe33585370b146a46c597d6750953",
"version" : "1.23.1"
}
}
],
"version" : 2
}
@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "onnxruntime-swift-package-manager",
"kind" : "remoteSourceControl",
"location" : "https://github.com/masicai/onnxruntime-swift-package-manager",
"state" : {
"revision" : "57f28b1fdd6fe33585370b146a46c597d6750953",
"version" : "1.23.1"
}
}
],
"version" : 2
}
+3
View File
@@ -22,5 +22,8 @@
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<!-- image_picker: read photos the user explicitly chooses -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict> </dict>
</plist> </plist>
+2
View File
@@ -32,5 +32,7 @@
<string>MeshCore needs Bluetooth to communicate with LoRa mesh devices</string> <string>MeshCore needs Bluetooth to communicate with LoRa mesh devices</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>This app uses the camera to scan QR codes for joining communities.</string> <string>This app uses the camera to scan QR codes for joining communities.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app lets you pick a photo to compress and send over the mesh.</string>
</dict> </dict>
</plist> </plist>
+3
View File
@@ -18,5 +18,8 @@
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<!-- image_picker: read photos the user explicitly chooses -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict> </dict>
</plist> </plist>
+6
View File
@@ -58,6 +58,7 @@ dependencies:
characters: ^1.4.0 characters: ^1.4.0
package_info_plus: ^10.1.0 package_info_plus: ^10.1.0
mobile_scanner: ^7.1.4 # QR/barcode scanning mobile_scanner: ^7.1.4 # QR/barcode scanning
image_picker: ^1.1.2 # Pick photos to send as compressed mesh images
qr_flutter: ^4.1.0 # QR code generation qr_flutter: ^4.1.0 # QR code generation
url_launcher: ^6.3.0 # Launch URLs in system browser url_launcher: ^6.3.0 # Launch URLs in system browser
flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text
@@ -76,6 +77,11 @@ dependencies:
cryptography: ^2.9.0 cryptography: ^2.9.0
cryptography_flutter: ^2.3.4 cryptography_flutter: ^2.3.4
convert: ^3.1.2 convert: ^3.1.2
# ONNX Runtime 1.23.0 via platform channels. Powers the AEIC-SE image codec
# decoder (lib/services/image_codec_backend.dart). arm64-only on Android — see
# the abiFilters note in android/app/build.gradle.kts; a universal APK pays
# ~56 MB for ORT instead of ~18 MB.
flutter_onnxruntime: ^1.8.3
hooks: hooks:
user_defines: user_defines:
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/main.dart';
import 'package:meshcore_open/services/image_chunk_transport.dart';
import 'package:meshcore_open/services/received_image_store.dart';
import 'package:meshcore_open/models/app_settings.dart';
import 'package:meshcore_open/models/image_codec_support.dart';
import 'package:meshcore_open/widgets/image_send_codec_binding.dart';
void main() {
test('ImageStreamReassembler.selfPrefix override suppresses our own echo', () {
final store = ReceivedImageStore();
final r = ImageStreamReassembler(store: store);
final set = buildImageChunks(
payload: Uint8List.fromList(List<int>.generate(120, (i) => i)),
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
senderPrefix: 0xABCD,
imgId: 7,
);
// Before SELF_INFO the prefix is unknown, so nothing is suppressed.
expect(r.addChunk(set.blobs[0], channelIndex: 1).status,
ImageChunkStatus.completed);
r.clear();
// After SELF_INFO the superclass must see the overridden getter.
r.selfPrefix = 0xABCD;
expect(r.addChunk(set.blobs[0], channelIndex: 1).status,
ImageChunkStatus.fromSelf);
});
test('addChunk forwards outcomes to the store with the channel index',
() async {
final store = ReceivedImageStore();
final r = ImageStreamReassembler(store: store);
final set = buildImageChunks(
payload: Uint8List.fromList(List<int>.generate(400, (i) => i & 0xFF)),
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
senderPrefix: 0x1234,
imgId: 3,
);
r.addChunk(set.blobs[0], channelIndex: 5);
await store.settle();
final entry = store.entries.single;
expect(entry.channelIndex, 5);
expect(entry.senderPrefix, 0x1234);
expect(entry.state, ReceivedImageState.receiving);
});
group('AppSettings image codec block', () {
test('round-trips through toJson/fromJson on the ImageCodecPreferences keys',
() {
final settings = AppSettings(
imageCodecEnabled: true,
imageCodecSelectedModelId: 'qdq_conv_pct_novae',
imageCodecModelSourceUrl: 'https://example.invalid/model.onnx',
imageCodecRatePoint: 4,
imageCodecDownloadedModels: [
ImageCodecModelRecord(
id: 'qdq_conv_pct_novae',
name: 'AEIC ft32 (int8)',
sourceUrl: 'https://example.invalid/model.onnx',
localPath: '/tmp/model.onnx',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(1000),
fileSizeBytes: 872 * 1024 * 1024,
),
],
);
final json = settings.toJson();
// The keys must be the ones ImageCodecPreferences already used, so a
// build that wrote them through the old standalone store still reads.
expect(json['image_codec_enabled'], isTrue);
expect(json['image_codec_selected_model_id'], 'qdq_conv_pct_novae');
expect(json['image_codec_rate_point'], 4);
final restored = AppSettings.fromJson(json);
expect(restored.imageCodecEnabled, isTrue);
expect(restored.imageCodecSelectedModelId, 'qdq_conv_pct_novae');
expect(
restored.imageCodecModelSourceUrl,
'https://example.invalid/model.onnx',
);
expect(restored.imageCodecRatePoint, 4);
expect(restored.imageCodecDownloadedModels.single.localPath,
'/tmp/model.onnx');
});
test('defaults are off, ft32, and empty', () {
const prefs = ImageCodecPreferences();
final settings = AppSettings();
expect(settings.imageCodecEnabled, prefs.enabled);
expect(settings.imageCodecRatePoint, prefs.ratePoint);
expect(settings.imageCodecDownloadedModels, isEmpty);
expect(settings.imageCodec.aeicRatePoint, AeicRatePoint.ft32);
});
test('the assembled view matches the five fields', () {
final settings = AppSettings(
imageCodecEnabled: true,
imageCodecSelectedModelId: 'x',
imageCodecRatePoint: 4,
);
expect(settings.imageCodec.enabled, isTrue);
expect(settings.imageCodec.selectedModelId, 'x');
expect(settings.imageCodec.ratePoint, 4);
});
});
}
+510
View File
@@ -0,0 +1,510 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/image_chunk_transport.dart';
import 'package:meshcore_open/widgets/image_send_codec_binding.dart';
import 'package:meshcore_open/models/radio_settings.dart';
import 'package:meshcore_open/utils/lora_airtime.dart';
double _ms(Duration d) => d.inMicroseconds / 1000.0;
RadioSettings _radio({
LoRaSpreadingFactor sf = LoRaSpreadingFactor.sf10,
LoRaBandwidth bw = LoRaBandwidth.bw250,
LoRaCodingRate cr = LoRaCodingRate.cr4_5,
}) =>
RadioSettings(
frequencyMHz: 869.525,
bandwidth: bw,
spreadingFactor: sf,
codingRate: cr,
txPowerDbm: 22,
);
void main() {
group('loraTimeOnAir reference values (255-byte packet)', () {
test('SF9, CR 4/8, BW 250 kHz -> 975 ms', () {
final toa = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 8,
);
expect(_ms(toa), closeTo(975, 1));
});
test('SF10, CR 4/5, BW 250 kHz -> 1148 ms', () {
final toa = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 10,
bandwidthHz: 250000,
codingRate: 5,
);
expect(_ms(toa), closeTo(1148, 1));
});
});
group('low data rate optimize', () {
test('SF12 / BW 125 kHz engages LDRO (Tsym = 32.768 ms > 16 ms)', () {
final toa = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 12,
bandwidthHz: 125000,
codingRate: 5,
);
// DE = 1 -> denominator 4*(12-2) = 40 -> 51 * 5 = 255 payload symbols
// ToA = (12.25 + 263) * 32.768 ms
expect(_ms(toa), closeTo(9019.392, 1));
});
test('SF12 / BW 500 kHz does NOT engage LDRO (Tsym = 8.192 ms)', () {
final toa = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 12,
bandwidthHz: 500000,
codingRate: 5,
);
// DE = 0 -> denominator 48 -> 43 * 5 = 215 payload symbols
// ToA = (12.25 + 223) * 8.192 ms
expect(_ms(toa), closeTo(1927.9296, 1));
});
test('SF11 / BW 250 kHz does NOT engage LDRO (Tsym = 8.192 ms)', () {
// Guards against the common `sf >= 11` shortcut, which is wrong here.
final withSf11 = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 11,
bandwidthHz: 250000,
codingRate: 5,
);
// DE = 0 -> denominator 44 -> 47 * 5 = 235 payload symbols
// ToA = (12.25 + 243) * 8.192 ms
expect(_ms(withSf11), closeTo(2091.008, 1));
});
});
group('airtime monotonicity / sanity', () {
test('longer payload never takes less airtime', () {
Duration at(int pl) => loraTimeOnAir(
payloadBytes: pl,
spreadingFactor: 10,
bandwidthHz: 250000,
codingRate: 5,
);
var previous = at(0);
for (var pl = 1; pl <= 255; pl++) {
final current = at(pl);
expect(current.inMicroseconds,
greaterThanOrEqualTo(previous.inMicroseconds));
previous = current;
}
});
test('zero and one byte payloads do not crash and are positive', () {
for (final pl in [0, 1]) {
final toa = loraTimeOnAir(
payloadBytes: pl,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 5,
);
expect(toa.inMicroseconds, greaterThan(0));
}
});
});
group('normalizeCodingRate', () {
test('maps both firmware encodings to 5..8', () {
expect(normalizeCodingRate(1), 5);
expect(normalizeCodingRate(4), 8);
expect(normalizeCodingRate(5), 5);
expect(normalizeCodingRate(8), 8);
});
test('raw 1..4 and 5..8 produce identical airtime after normalisation', () {
final a = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: normalizeCodingRate(4),
);
final b = loraTimeOnAir(
payloadBytes: 255,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: normalizeCodingRate(8),
);
expect(a, b);
expect(_ms(a), closeTo(975, 1));
});
});
group('chunk counts for measured codec payload sizes', () {
test('ft32 "standard" (110 / 155.8 / 209 bytes) -> 1-2 chunks', () {
// Derived from kImageChunkFirstCapacity, never hardcoded: that constant
// has already moved twice (2->4 byte header, then a 2-byte CRC added to
// chunk 0), and each time a hardcoded expectation here would have hidden
// the estimator drifting away from the chunker.
expect(imageChunkCount(110), 1);
expect(imageChunkCount(209), 2);
expect(
imageChunkCount(156),
156 <= kImageChunkFirstCapacity ? 1 : 2,
);
for (final pl in [110, 156, 209]) {
expect(imageChunkCount(pl), inInclusiveRange(1, 2));
}
});
test('ft16 "high" (176 / 288 / 409 bytes) -> 2-3 chunks', () {
expect(imageChunkCount(176), 2);
expect(imageChunkCount(288), 2);
expect(imageChunkCount(409), 3);
for (final pl in [176, 288, 409]) {
expect(imageChunkCount(pl), inInclusiveRange(2, 3));
}
});
test('chunk 0 carries one fewer payload byte (boundary handling)', () {
// Derived from the transport's constants, never hardcoded: these numbers
// moved once already when the header grew from 2 to 4 bytes to carry the
// sender prefix, and a hardcoded test hid the estimator disagreeing with
// the chunker.
const first = kImageChunkFirstCapacity;
const rest = kImageChunkCapacity;
expect(first, rest - kImageChunkZeroMetadataBytes);
expect(imageChunkCount(first), 1);
expect(imageChunkCount(first + 1), 2);
expect(imageChunkCount(first + rest), 2);
expect(imageChunkCount(first + rest + 1), 3);
expect(imageChunkCount(0), 0);
expect(imageChunkCount(1), 1);
});
test('chunk payload sizes sum to the payload', () {
for (final pl in [0, 1, 110, 162, 163, 209, 288, 409, 1000]) {
expect(imageChunkPayloadSizes(pl).fold<int>(0, (a, b) => a + b), pl);
}
});
});
group('estimateSend', () {
test('parity adds exactly one packet', () {
final radio = _radio();
for (final pl in [110, 209, 288, 409]) {
final without =
estimateSend(payloadBytes: pl, radio: radio, parity: false);
final with_ = estimateSend(payloadBytes: pl, radio: radio);
expect(with_.chunkCount, without.chunkCount + 1);
expect(without.includesParity, isFalse);
expect(with_.includesParity, isTrue);
expect(with_.totalAirtime!.inMicroseconds,
greaterThan(without.totalAirtime!.inMicroseconds));
}
});
test('unknown radio settings -> packet count kept, airtime null', () {
final est = estimateSend(payloadBytes: 288, radio: null);
expect(est.chunkCount, 3); // 2 data chunks + parity
expect(est.totalBytes, greaterThan(288));
expect(est.perPacketAirtime, isNull);
expect(est.totalAirtime, isNull);
expect(est.hasAirtime, isFalse);
});
test('partially unknown radio params also yield a null airtime', () {
final est = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 10,
bandwidthHz: null,
codingRate: 5,
);
expect(est.chunkCount, 3);
expect(est.hasAirtime, isFalse);
});
test('raw firmware coding rate 1..4 is normalised', () {
final a = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 4, // firmware 1..4 encoding for 4/8
);
final b = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 8, // 5..8 encoding for 4/8
);
expect(a, b);
});
test('zero-byte payload does not crash and adds no parity', () {
final est = estimateSend(payloadBytes: 0, radio: _radio());
expect(est.chunkCount, 0);
expect(est.totalBytes, 0);
expect(est.includesParity, isFalse);
expect(est.totalAirtime, Duration.zero);
});
test('one-byte payload is a single chunk plus parity', () {
final est = estimateSend(payloadBytes: 1, radio: _radio());
expect(est.chunkCount, 2);
expect(est.totalAirtime!.inMicroseconds, greaterThan(0));
});
test('total bytes account for chunk headers and metadata', () {
final est =
estimateSend(payloadBytes: 209, radio: _radio(), parity: false);
// A data blob is header + body (chunk 0's body opens with the metadata
// byte). Only the PARITY blob carries the length byte, and it is always a
// full kImageChunkBlobBytes because the XOR body is zero-padded.
final sizes = imageChunkPayloadSizes(209);
var expected = 0;
for (var i = 0; i < sizes.length; i++) {
expected += kImageChunkHeaderBytes +
(i == 0 ? kImageChunkZeroMetadataBytes : 0) +
sizes[i];
}
expect(est.chunkCount, 2);
expect(est.totalBytes, expected);
// Sanity: payload + per-chunk header + the one metadata byte.
expect(
expected,
209 + 2 * kImageChunkHeaderBytes + kImageChunkZeroMetadataBytes,
);
});
test('total airtime equals the sum of the per-chunk airtimes', () {
final est = estimateSend(
payloadBytes: 409,
radio: _radio(sf: LoRaSpreadingFactor.sf9, cr: LoRaCodingRate.cr4_8),
parity: false,
);
final sizes = imageChunkPayloadSizes(409);
var expected = 0;
for (var i = 0; i < sizes.length; i++) {
expected += loraTimeOnAir(
payloadBytes: kImageChunkHeaderBytes +
(i == 0 ? kImageChunkZeroMetadataBytes : 0) +
sizes[i],
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 8,
).inMicroseconds;
}
expect(est.totalAirtime!.inMicroseconds, expected);
});
test('per-packet airtime is the airtime of a full chunk packet', () {
final est = estimateSend(
payloadBytes: 409,
radio: _radio(sf: LoRaSpreadingFactor.sf10, cr: LoRaCodingRate.cr4_5),
);
final full = loraTimeOnAir(
payloadBytes: kImageChunkBlobBytes,
spreadingFactor: 10,
bandwidthHz: 250000,
codingRate: 5,
);
expect(est.perPacketAirtime, full);
});
test('a realistic ft16 image on SF10/BW250/CR4-5 stays under ~5 s', () {
final est = estimateSend(payloadBytes: 288, radio: _radio());
expect(est.chunkCount, 3);
expect(est.totalAirtime!.inMilliseconds, greaterThan(1000));
expect(est.totalAirtime!.inMilliseconds, lessThan(5000));
});
});
group('paced wall clock', () {
test('single-packet send has no pacing gap', () {
final est =
estimateSend(payloadBytes: 110, radio: _radio(), parity: false);
expect(est.chunkCount, 1);
expect(est.pacedWallClock, est.totalAirtime);
});
test('multi-packet send adds one gap per inter-packet boundary', () {
final est = estimateSend(payloadBytes: 209, radio: _radio());
expect(est.chunkCount, 3); // 2 data + parity
final sizes = imageChunkPayloadSizes(209);
const framing = kImageChunkHeaderBytes + kImageParityLengthBytes;
final packetBytes = <int>[
framing + kImageChunkZeroMetadataBytes + sizes[0],
framing + sizes[1],
// parity body is as large as the largest data body
framing +
(sizes[0] + kImageChunkZeroMetadataBytes > sizes[1]
? sizes[0] + kImageChunkZeroMetadataBytes
: sizes[1]),
];
var airtime = 0;
var wall = 0;
for (var i = 0; i < packetBytes.length; i++) {
final toa = loraTimeOnAir(
payloadBytes: packetBytes[i],
spreadingFactor: 10,
bandwidthHz: 250000,
codingRate: 5,
);
airtime += toa.inMicroseconds;
wall += toa.inMicroseconds;
if (i != packetBytes.length - 1) {
wall += imageSendChunkGap(toa).inMicroseconds;
}
}
expect(est.totalAirtime!.inMicroseconds, airtime);
expect(est.pacedWallClock!.inMicroseconds, wall);
// Two boundaries, so at least two base delays of extra wall clock.
expect(
wall - airtime,
greaterThanOrEqualTo(2 * kImageSendChunkGapBase.inMicroseconds),
);
});
test('the gap is the documented base plus airtime factor', () {
const toa = Duration(milliseconds: 300);
expect(
imageSendChunkGap(toa),
Duration(
microseconds: kImageSendChunkGapBase.inMicroseconds +
(toa.inMicroseconds * kImageSendChunkGapAirtimeFactor).round(),
),
);
});
test('unknown radio settings leave the wall clock null too', () {
final est = estimateSend(payloadBytes: 156, radio: null);
expect(est.pacedWallClock, isNull);
expect(est.totalAirtime, isNull);
expect(est.chunkCount, imageChunkCount(156) + 1); // + parity
});
test('a realistic ft32 image on SF10/BW250/CR4-5 is a few seconds', () {
// 110-209 B measured => 1-2 data chunks + parity. The paced figure is
// what the compose sheet shows, so it must stay plausible.
for (final pl in [110, 156, 209]) {
final est = estimateSend(payloadBytes: pl, radio: _radio());
expect(est.chunkCount, inInclusiveRange(2, 3));
expect(est.pacedWallClock!.inMilliseconds, greaterThan(1000));
expect(est.pacedWallClock!.inMilliseconds, lessThan(10000));
expect(
est.pacedWallClock!.inMicroseconds,
greaterThan(est.totalAirtime!.inMicroseconds),
);
}
});
});
group('malformed radio parameters from the wire', () {
// currentSf/currentBwHz/currentCr are raw bytes off the device. A
// disconnected or half-initialised radio reports zeroes, which used to
// reach the ToA maths and throw "Unsupported operation: Infinity or NaN
// toInt" in release builds. Packet counts must survive; airtime must go
// null rather than be invented.
test('zero spreading factor yields packet counts but no airtime', () {
final est = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 0,
bandwidthHz: 250000,
codingRate: 5,
);
expect(est.chunkCount, greaterThan(0));
expect(est.totalBytes, greaterThan(0));
expect(est.totalAirtime, isNull);
expect(est.perPacketAirtime, isNull);
});
test('zero bandwidth yields no airtime', () {
final est = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 9,
bandwidthHz: 0,
codingRate: 8,
);
expect(est.totalAirtime, isNull);
});
test('zero coding rate yields no airtime', () {
// normalizeCodingRate(0) == 4, which is still outside the legal 5..8.
final est = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 0,
);
expect(est.totalAirtime, isNull);
});
test('out-of-range spreading factors are rejected at both ends', () {
for (final sf in [4, 13, 255]) {
final est = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: sf,
bandwidthHz: 250000,
codingRate: 5,
);
expect(est.totalAirtime, isNull, reason: 'sf=$sf must not produce airtime');
}
});
test('valid params still produce airtime after the guard', () {
final est = estimateSendFromRadioParams(
payloadBytes: 288,
spreadingFactor: 9,
bandwidthHz: 250000,
codingRate: 4, // 1..4 firmware encoding -> normalises to 4/8
);
expect(est.totalAirtime, isNotNull);
expect(est.totalAirtime!.inMilliseconds, greaterThan(0));
});
test('areLoRaParamsValid accepts the boundary values', () {
expect(areLoRaParamsValid(spreadingFactor: 5, bandwidthHz: 7800, codingRate: 5), isTrue);
expect(areLoRaParamsValid(spreadingFactor: 12, bandwidthHz: 500000, codingRate: 8), isTrue);
expect(areLoRaParamsValid(spreadingFactor: null, bandwidthHz: 250000, codingRate: 5), isFalse);
});
});
group('estimator agrees with the real chunker', () {
// The estimator used to charge the parity-length byte to every data chunk
// and size the parity blob from the largest data body. Both were wrong:
// only parity carries that byte, and its XOR body is always zero-padded to
// full. A 110-byte payload was reported as 232 on-air bytes against a real
// 278 a 17% understatement of airtime on the smallest, most common image.
// Compare against buildImageChunks() rather than restating the arithmetic.
for (final payload in <int>[1, 110, 156, 157, 158, 209, 288, 409]) {
test('$payload-byte payload matches buildImageChunks byte for byte', () {
for (final parity in <bool>[false, true]) {
final set = buildImageChunks(
payload: Uint8List(payload),
metadata: const ImageStreamMetadata(
rate: ImageCodecRatePoint.standard,
),
senderPrefix: 0x1234,
imgId: 7,
parity: parity,
);
final actual = set.blobs.fold<int>(0, (a, b) => a + b.length);
final est = estimateSend(
payloadBytes: payload,
radio: _radio(),
parity: parity,
);
expect(
est.totalBytes,
actual,
reason: 'payload $payload, parity $parity',
);
expect(
est.chunkCount,
set.blobs.length,
reason: 'payload $payload, parity $parity',
);
}
});
}
});
}
+506
View File
@@ -0,0 +1,506 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/image_codec_support.dart';
import 'package:meshcore_open/widgets/image_send_codec_binding.dart';
void main() {
group('ImageCodecModelRecord', () {
test('round-trips through JSON', () {
final record = ImageCodecModelRecord(
id: 'aeic-se-512-int8',
name: 'aeic_se_512_int8.onnx',
sourceUrl: 'https://example.invalid/model.onnx',
localPath: '/tmp/aeic_se_512_int8.onnx',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000),
fileSizeBytes: 873463808,
assetFileNames: ['a.onnx', 'a.onnx.data', 'e.onnx', 't.bin'],
bundleVersion: kImageCodecBundleVersion,
);
final restored = ImageCodecModelRecord.fromJson(record.toJson());
expect(restored.id, record.id);
expect(restored.name, record.name);
expect(restored.sourceUrl, record.sourceUrl);
expect(restored.localPath, record.localPath);
expect(restored.downloadedAt, record.downloadedAt);
expect(restored.fileSizeBytes, record.fileSizeBytes);
expect(restored.assetFileNames, record.assetFileNames);
expect(restored.bundleVersion, kImageCodecBundleVersion);
});
test('tolerates a missing/garbage payload', () {
final restored = ImageCodecModelRecord.fromJson(<String, dynamic>{});
expect(restored.id, '');
expect(restored.fileSizeBytes, 0);
expect(restored.downloadedAt.millisecondsSinceEpoch, 0);
expect(restored.assetFileNames, isEmpty);
expect(restored.bundleVersion, 0);
});
test('a record written by a pre-bundle build reads as version 0', () {
// The exact JSON the decoder-only build wrote. It must still load, and it
// must be recognisable as needing an upgrade rather than silently
// claiming to be a full bundle.
final restored = ImageCodecModelRecord.fromJson(<String, dynamic>{
'id': 'aeic-se-decoder-qdq-conv-pct-novae',
'name': 'aeic_decoder_qdq_conv_pct_novae.onnx',
'source_url': 'https://example.invalid/x.onnx',
'local_path': '/tmp/aeic_decoder_qdq_conv_pct_novae.onnx',
'downloaded_at': 1730000000000,
'file_size_bytes': 2909610,
});
expect(restored.bundleVersion, 0);
expect(restored.bundleVersion, lessThan(kImageCodecBundleVersion));
expect(restored.assetFileNames, isEmpty);
expect(restored.localPath, isNotEmpty);
expect(restored.assetRoles, isEmpty);
});
test('asset roles survive JSON and tell the two entropy graphs apart', () {
// The whole point of the field: `e_send.onnx` and `e_decode.onnx` are
// indistinguishable by name, extension or position, and swapping them is
// the silent-corruption failure mode.
final record = ImageCodecModelRecord(
id: 'bundle',
name: 'd.onnx',
sourceUrl: 'https://example.invalid/d.onnx',
localPath: '/tmp/d.onnx',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000),
fileSizeBytes: 1,
assetFileNames: const [
'd.onnx',
'd.onnx.data',
'e_send.onnx',
'e_decode.onnx',
't.bin',
],
assetRoles: const {
'd.onnx': ImageCodecAssetRole.decoderGraph,
'd.onnx.data': ImageCodecAssetRole.decoderWeights,
'e_send.onnx': ImageCodecAssetRole.entropyGraph,
'e_decode.onnx': ImageCodecAssetRole.entropyDecodeGraph,
't.bin': ImageCodecAssetRole.cdfTables,
},
bundleVersion: kImageCodecBundleVersion,
);
// Serialised by NAME, so appending an enum member cannot re-label a file
// that is already installed.
expect(record.toJson()['asset_roles'], {
'd.onnx': 'decoderGraph',
'd.onnx.data': 'decoderWeights',
'e_send.onnx': 'entropyGraph',
'e_decode.onnx': 'entropyDecodeGraph',
't.bin': 'cdfTables',
});
final restored = ImageCodecModelRecord.fromJson(record.toJson());
expect(
restored.fileNameForRole(ImageCodecAssetRole.entropyGraph),
'e_send.onnx',
);
expect(
restored.fileNameForRole(ImageCodecAssetRole.entropyDecodeGraph),
'e_decode.onnx',
);
expect(
restored.fileNameForRole(ImageCodecAssetRole.entropyWeights),
isNull,
);
});
test('an unknown role name is dropped, not coerced to a real role', () {
// A record written by a newer build. Filing its unknown file under some
// existing role would hand ORT the wrong graph.
final restored = ImageCodecModelRecord.fromJson(<String, dynamic>{
'asset_file_names': ['a.onnx', 'b.bin'],
'asset_roles': {'a.onnx': 'somethingFromTheFuture', 'b.bin': 'cdfTables'},
});
expect(restored.assetRoles.keys, ['b.bin']);
expect(restored.fileNameForRole(ImageCodecAssetRole.cdfTables), 'b.bin');
expect(parseImageCodecAssetRole('somethingFromTheFuture'), isNull);
expect(
parseImageCodecAssetRole('entropyDecodeGraph'),
ImageCodecAssetRole.entropyDecodeGraph,
);
});
test('a role naming a file that is not installed does not resolve', () {
// assetRoles and assetFileNames can disagree if a file was reaped; the
// list of what is actually on disk wins.
final record = ImageCodecModelRecord(
id: 'x',
name: 'd.onnx',
sourceUrl: '',
localPath: '/tmp/d.onnx',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(0),
fileSizeBytes: 0,
assetFileNames: const ['d.onnx'],
assetRoles: const {
'd.onnx': ImageCodecAssetRole.decoderGraph,
'gone.onnx': ImageCodecAssetRole.entropyDecodeGraph,
},
);
expect(
record.fileNameForRole(ImageCodecAssetRole.entropyDecodeGraph),
isNull,
);
});
});
group('ImageCodecBundle', () {
test('is complete only with both entropy graph and tables', () {
const decoderOnly = ImageCodecBundle(decoderGraphPath: '/m/d.onnx');
expect(decoderOnly.isComplete, isFalse);
const noTables = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
);
expect(noTables.isComplete, isFalse);
const noEntropy = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
tablesPath: '/m/t.bin',
);
expect(noEntropy.isComplete, isFalse);
const full = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
tablesPath: '/m/t.bin',
);
expect(full.isComplete, isTrue);
});
test('supportsDecode additionally requires the decode-side graph', () {
// Bundle version 1: the send-side entropy graph only. It can encode --
// that graph emits every stage at once, which is all an encoder needs --
// but decoding is sequential and needs the If-branched export.
const sendOnly = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
tablesPath: '/m/t.bin',
);
expect(sendOnly.isComplete, isTrue);
expect(sendOnly.supportsDecode, isFalse);
const both = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
entropyDecodeGraphPath: '/m/e_dec.onnx',
tablesPath: '/m/t.bin',
);
expect(both.supportsDecode, isTrue);
// A decode-side graph without the tables is still useless.
const noTables = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
entropyDecodeGraphPath: '/m/e_dec.onnx',
);
expect(noTables.supportsDecode, isFalse);
});
test('the decode-side graph participates in equality', () {
// The service keys its cached session on the bundle. If this field were
// left out of ==, upgrading a v1 install in place would reuse an isolate
// that never learned about the new graph.
const sendOnly = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
tablesPath: '/m/t.bin',
);
const both = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
entropyDecodeGraphPath: '/m/e_dec.onnx',
tablesPath: '/m/t.bin',
);
expect(both, isNot(sendOnly));
expect(both.hashCode, isNot(sendOnly.hashCode));
expect(both.toString(), contains('/m/e_dec.onnx'));
});
test('defaults to the shipping rate point and compares by value', () {
const a = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
tablesPath: '/m/t.bin',
);
const b = ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
tablesPath: '/m/t.bin',
);
expect(a.ratePoint, kShippingAeicRatePoint);
// The service keys its cached session on the bundle; value equality is
// what stops an identical bundle from respawning the isolate.
expect(a, b);
expect(a.hashCode, b.hashCode);
expect(
a,
isNot(
const ImageCodecBundle(
decoderGraphPath: '/m/d.onnx',
entropyGraphPath: '/m/e.onnx',
tablesPath: '/m/other.bin',
),
),
);
});
});
group('AeicRatePoint', () {
test('ordinals are the persisted/isolate wire values', () {
expect(AeicRatePoint.ft2.wireValue, 0);
expect(AeicRatePoint.ft32.wireValue, 4);
expect(parseAeicRatePoint(4), AeicRatePoint.ft32);
});
test('parse falls back to ft32 for out-of-range values', () {
expect(parseAeicRatePoint(-1), AeicRatePoint.ft32);
expect(parseAeicRatePoint(99), AeicRatePoint.ft32);
});
test('label carries the measured mean size where one exists', () {
expect(AeicRatePoint.ft32.label, 'ft32 (~156 B)');
expect(AeicRatePoint.ft2.label, 'ft2');
});
test('ft32 is the only shipping rate point', () {
expect(kShippingAeicRatePoint, AeicRatePoint.ft32);
// ft16 was dropped: 2-3 data chunks with 79 B of headroom. The UI mapping
// is constant now, so no selector value can reach another checkpoint.
for (final rate in ImageCodecRatePoint.values) {
expect(aeicRatePointForUi(rate), AeicRatePoint.ft32);
}
for (final rate in AeicRatePoint.values) {
expect(uiRatePointForAeic(rate), ImageCodecRatePoint.standard);
}
});
test('ft32 measurements are the real corpus numbers', () {
expect(AeicRatePoint.ft32.meanBytes, 156); // 155.8 B over 26 images
expect(AeicRatePoint.ft32.maxBytes, 209);
});
test('UI index and model ordinal are NOT interchangeable', () {
// Guards the trap documented on AeicRatePoint: the on-air nibble is
// ImageCodecRatePoint.index, the settings/isolate value is this ordinal.
expect(
ImageCodecRatePoint.standard.index,
isNot(AeicRatePoint.ft32.wireValue),
);
});
});
group('ImageCodecPreferences', () {
test('defaults to disabled at ft32 with no models', () {
const prefs = ImageCodecPreferences();
expect(prefs.enabled, isFalse);
expect(prefs.aeicRatePoint, AeicRatePoint.ft32);
expect(prefs.downloadedModels, isEmpty);
});
test('round-trips through JSON including nested models', () {
final preset = imageCodecPresetModels.first;
final prefs = ImageCodecPreferences(
enabled: true,
selectedModelId: preset.id,
modelSourceUrl: preset.graph.sourceUrl,
ratePoint: AeicRatePoint.ft32.wireValue,
downloadedModels: [
ImageCodecModelRecord(
id: preset.id,
name: preset.fileName,
sourceUrl: preset.graph.sourceUrl,
localPath: '/tmp/${preset.fileName}',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000),
fileSizeBytes: preset.graph.sizeBytes,
),
],
);
final restored = ImageCodecPreferences.fromJson(prefs.toJson());
expect(restored.enabled, isTrue);
expect(restored.selectedModelId, preset.id);
expect(restored.modelSourceUrl, prefs.modelSourceUrl);
expect(restored.aeicRatePoint, AeicRatePoint.ft32);
expect(restored.downloadedModels, hasLength(1));
expect(restored.downloadedModels.first.id, preset.id);
});
test('copyWith can clear nullable strings via the sentinel', () {
const prefs = ImageCodecPreferences(selectedModelId: 'x');
expect(prefs.copyWith().selectedModelId, 'x');
expect(prefs.copyWith(selectedModelId: null).selectedModelId, isNull);
});
});
group('imageCodecPresetModels', () {
test('ships exactly one model: one ft32 bundle, not two downloads', () {
expect(imageCodecPresetModels, hasLength(1));
final preset = imageCodecPresetModels.single;
expect(preset.id, 'aeic-se-ft32-bundle-v1');
expect(preset.ratePoint, AeicRatePoint.ft32);
expect(preset.isComplete, isTrue);
expect(preset.totalSizeBytes, kImageCodecBundleTotalBytes);
// 958.0 MiB across the five files, measured with stat on the local
// exports. See the upload manifest in image_codec_support.dart.
expect(preset.totalSizeBytes, 1004548432);
});
test('ships qdq_conv_pct, NOT the novae variant', () {
// The novae variant leaves the VAE in fp32: +38 MB on disk and +0.83 GiB
// peak RSS for 0.17 dB. RAM is the binding constraint on a phone.
final preset = imageCodecPresetModels.single;
expect(preset.fileName, 'aeic_decoder_qdq_conv_pct.onnx');
for (final asset in preset.assets) {
expect(asset.fileName, isNot(contains('novae')));
}
expect(preset.assetFor(ImageCodecAssetRole.decoderWeights).sizeBytes,
872896480);
});
test('carries all five roles exactly once', () {
final preset = imageCodecPresetModels.single;
expect(preset.assets, hasLength(5));
for (final role in const [
ImageCodecAssetRole.decoderGraph,
ImageCodecAssetRole.decoderWeights,
ImageCodecAssetRole.entropyGraph,
ImageCodecAssetRole.entropyDecodeGraph,
ImageCodecAssetRole.cdfTables,
]) {
expect(
preset.assets.where((a) => a.role == role),
hasLength(1),
reason: 'exactly one asset per role: $role',
);
}
// Reserved and deliberately absent: the fp32 entropy export is
// self-contained.
expect(preset.maybeAssetFor(ImageCodecAssetRole.entropyWeights), isNull);
});
test('resolves the graph by role, not by list position', () {
final preset = imageCodecPresetModels.single;
expect(preset.graph, preset.assetFor(ImageCodecAssetRole.decoderGraph));
expect(preset.graph.fileName, endsWith('.onnx'));
expect(
preset.assetFor(ImageCodecAssetRole.decoderWeights).fileName,
'${preset.graph.fileName}.data',
reason: 'ORT resolves external data by the exact filename in the graph',
);
// A reordered list must not change which file ORT is handed.
final reordered = ImageCodecModelSpec(
id: preset.id,
label: preset.label,
assets: preset.assets.reversed.toList(),
);
expect(reordered.fileName, preset.fileName);
});
test('the entropy graph and the tables are the ones that were validated',
() {
final preset = imageCodecPresetModels.single;
final entropy = preset.assetFor(ImageCodecAssetRole.entropyGraph);
// op17 fp32: byte-identical bitstreams on 26/26 images. Not op20, not int8.
expect(entropy.fileName, 'aeic_entropy_side_fp32_op17.onnx');
expect(entropy.sizeBytes, 67262167);
// The decode-side export of the same weights. fp32 as well: int8 there
// breaks the bit-exactness the rANS decoder depends on.
final decode = preset.assetFor(ImageCodecAssetRole.entropyDecodeGraph);
expect(decode.fileName, 'aeic_entropy_decode_fp32_op17.onnx');
expect(decode.sizeBytes, 60509540);
expect(decode.fileName, isNot(entropy.fileName));
final tables = preset.assetFor(ImageCodecAssetRole.cdfTables);
expect(tables.fileName, 'aeic_cdf_ft32.bin');
expect(tables.sizeBytes, 813648);
});
test('an incomplete spec is detectable', () {
final preset = imageCodecPresetModels.single;
final decoderOnly = ImageCodecModelSpec(
id: 'legacy',
label: 'Legacy',
assets: [
preset.assetFor(ImageCodecAssetRole.decoderGraph),
preset.assetFor(ImageCodecAssetRole.decoderWeights),
],
);
expect(decoderOnly.isComplete, isFalse);
expect(
decoderOnly.maybeAssetFor(ImageCodecAssetRole.cdfTables),
isNull,
);
expect(
() => decoderOnly.assetFor(ImageCodecAssetRole.cdfTables),
throwsStateError,
);
// A version-1 spec: everything except the decode-side graph. It would
// install a codec that can send and never receive, so downloadPresetModel
// must refuse it too.
final sendOnly = ImageCodecModelSpec(
id: 'v1',
label: 'Send-only',
assets: [
for (final asset in preset.assets)
if (asset.role != ImageCodecAssetRole.entropyDecodeGraph) asset,
],
);
expect(sendOnly.assets, hasLength(4));
expect(sendOnly.isComplete, isFalse);
});
test('uses the HuggingFace resolve/main URL shape', () {
for (final asset in imageCodecPresetModels.single.assets) {
expect(asset.sourceUrl, startsWith('https://huggingface.co/'));
expect(asset.sourceUrl, contains('/resolve/main/'));
expect(asset.sourceUrl, endsWith('?download=true'));
expect(asset.sourceUrl, contains(asset.fileName));
}
});
test('friendly name resolves through the registry', () {
final preset = imageCodecPresetModels.single;
final record = ImageCodecModelRecord(
id: preset.id,
name: preset.fileName,
sourceUrl: '',
localPath: '/tmp/x',
downloadedAt: DateTime.fromMillisecondsSinceEpoch(0),
fileSizeBytes: 0,
);
expect(imageCodecModelFriendlyName(record), preset.label);
});
test('latent contract matches the export', () {
expect(kImageCodecLatentShape, [1, 256, 16, 16]);
expect(kImageCodecLatentElements, 65536);
expect(kImageCodecDecoderInputName, 'y_hat');
});
});
group('exceptions', () {
test('an incomplete install is NOT the same failure as a missing build',
() {
// Both are ImageCodecUnimplemented, but only one has a remedy the user
// can act on, and the UI branches on exactly that difference.
const incomplete = ImageCodecBundleIncomplete();
const missing = ImageCodecEntropyPathMissing();
expect(incomplete, isA<ImageCodecUnimplemented>());
expect(missing, isA<ImageCodecUnimplemented>());
expect(incomplete, isNot(isA<ImageCodecEntropyPathMissing>()));
expect(incomplete.toString(), contains('re-download'));
});
});
group('parseImageCodecStatus', () {
test('maps known values and defaults to none', () {
expect(parseImageCodecStatus('completed'), ImageCodecStatus.completed);
expect(parseImageCodecStatus('nonsense'), ImageCodecStatus.none);
expect(parseImageCodecStatus(42), ImageCodecStatus.none);
});
});
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/entropy_tables.dart';
/// Structural checks on the shipped CDF table file. The invariants here are the
/// ones the rANS coder relies on; if any of them breaks, encoding silently
/// produces garbage rather than failing loudly.
void main() {
final Directory goldenDir = _resolveGoldenDir();
final Uint8List raw = File(
'${goldenDir.path}/aeic_cdf_ft32.bin',
).readAsBytesSync();
final Map<String, dynamic> manifest =
jsonDecode(File('${goldenDir.path}/manifest.json').readAsStringSync())
as Map<String, dynamic>;
final EntropyTables tables = EntropyTables.parse(raw);
test('header matches the manifest', () {
expect(raw.length, manifest['table_bytes']);
expect(tables.version, 1);
expect(tables.precision, manifest['precision']);
expect(tables.bypassPrecision, manifest['bypass_precision']);
expect(tables.streamParts, manifest['stream_parts']);
expect(tables.groups.length, 2);
});
test('group shapes match the manifest', () {
final List<Map<String, dynamic>> meta =
(manifest['table_groups'] as List<dynamic>)
.cast<Map<String, dynamic>>();
for (var g = 0; g < 2; g++) {
final CdfGroup group = tables.groups[g];
expect(group.numCdfs, meta[g]['rows'], reason: 'group $g rows');
expect(group.cdfWidth, meta[g]['width'], reason: 'group $g width');
expect(group.quantizedCdf.length, group.numCdfs * group.cdfWidth);
var lenMin = 1 << 30, lenMax = -(1 << 30);
var offMin = 1 << 30, offMax = -(1 << 30);
for (var r = 0; r < group.numCdfs; r++) {
lenMin = group.cdfLength[r] < lenMin ? group.cdfLength[r] : lenMin;
lenMax = group.cdfLength[r] > lenMax ? group.cdfLength[r] : lenMax;
offMin = group.offset[r] < offMin ? group.offset[r] : offMin;
offMax = group.offset[r] > offMax ? group.offset[r] : offMax;
}
expect(lenMin, meta[g]['cdf_length_min']);
expect(lenMax, meta[g]['cdf_length_max']);
expect(offMin, meta[g]['offset_min']);
expect(offMax, meta[g]['offset_max']);
}
expect(tables.zGroup.numCdfs, 128);
expect(tables.zGroup.cdfWidth, 19);
expect(tables.yGroup.numCdfs, 64);
expect(tables.yGroup.cdfWidth, 3133);
});
test('every CDF row is a valid, gap-free distribution', () {
for (var g = 0; g < tables.groups.length; g++) {
final CdfGroup group = tables.groups[g];
for (var r = 0; r < group.numCdfs; r++) {
final int n = group.cdfLength[r];
expect(n, greaterThanOrEqualTo(2), reason: 'group $g row $r length');
expect(n, lessThanOrEqualTo(group.cdfWidth));
expect(group.cdfAt(r, 0), 0, reason: 'group $g row $r first');
expect(
group.cdfAt(r, n - 1),
1 << 16,
reason: 'group $g row $r terminal',
);
for (var c = 0; c + 1 < n; c++) {
final int gap = group.cdfAt(r, c + 1) - group.cdfAt(r, c);
expect(
gap,
greaterThanOrEqualTo(1),
reason: 'group $g row $r has a zero-frequency symbol at $c',
);
}
for (var c = n; c < group.cdfWidth; c++) {
expect(group.cdfAt(r, c), 0, reason: 'group $g row $r padding at $c');
}
}
}
});
test('index-quantizer block parses', () {
final IndexQuantizerParams p = tables.indexQuantizer;
expect(p.scalesLevels, 64);
expect(p.scaleTable.length, 64);
expect(p.logScaleMin, closeTo(-2.2072749131897207, 1e-15));
expect(p.logScaleStep, closeTo(0.12305479932808384, 1e-15));
expect(p.scaleThreshold, closeTo(0.08, 1e-7));
expect(p.scaleFloor, closeTo(1e-5, 1e-11));
expect(p.scaleTable.first, closeTo(0.11, 1e-5));
expect(p.scaleTable.last, closeTo(256.0, 1e-3));
for (var i = 1; i < p.scaleTable.length; i++) {
expect(p.scaleTable[i], greaterThan(p.scaleTable[i - 1]));
}
});
test('rejects a corrupt magic', () {
final Uint8List bad = Uint8List.fromList(raw.sublist(0, 4096));
bad[3] ^= 0xFF;
expect(
() => EntropyTables.parse(bad),
throwsA(isA<EntropyTableFormatException>()),
);
});
test('rejects a truncated file', () {
expect(
() => EntropyTables.parse(Uint8List.fromList(raw.sublist(0, 1024))),
throwsA(isA<EntropyTableFormatException>()),
);
});
test('rejects trailing garbage', () {
final Uint8List extra = Uint8List(raw.length + 1)..setRange(0, raw.length, raw);
expect(
() => EntropyTables.parse(extra),
throwsA(isA<EntropyTableFormatException>()),
);
});
}
Directory _resolveGoldenDir() {
for (final String candidate in <String>[
'test/services/golden',
'../test/services/golden',
'golden',
]) {
final Directory d = Directory(candidate);
if (d.existsSync()) return d;
}
return Directory('test/services/golden');
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
{
"format": "aeic-entropy-e2e-recording",
"version": 1,
"checkpoint": "AEIC_SE_ft32.pkl",
"size": 512,
"files": [
{
"file": "kodim01.aeicrec",
"bytes": 7378887,
"sha256": "dd4278523e2a51d031e11b3d71e90f9ffe863d1244bf3de3896525ff0da9c049"
},
{
"file": "kodim02.aeicrec",
"bytes": 7378887,
"sha256": "8cc3043664c7c6d993baf6fd37db04f0e22954af114c1463b2000a5e230c2e20"
},
{
"file": "kodim05.aeicrec",
"bytes": 7378927,
"sha256": "535eef3213b0fdb0238bebad7e3baa041fe62aae1623fd9ec2deebd06c11ce1c"
},
{
"file": "image2.aeicrec",
"bytes": 7378902,
"sha256": "2779478763c22955bc0e61c22fb555dd0f016f3a5d3629a2272a66d1000d485e"
},
{
"file": "images.aeicrec",
"bytes": 7378878,
"sha256": "d7153bfd8bb9a1f7bd764eb7fcb43b1932205ac1b43be5b2c6ef6c8a5c57edb1"
}
]
}
+486
View File
@@ -0,0 +1,486 @@
{
"checkpoint": "AEIC_SE_ft32.pkl",
"size": 512,
"precision": 16,
"bypass_precision": 2,
"stream_parts": 2,
"table_file": "aeic_cdf_ft32.bin",
"table_bytes": 813648,
"table_sha256": "4089fde2af16c340642a5c857be42f6d0f21caf71dd5b4f32d62efcd41c77bd5",
"table_groups": [
{
"group": "z",
"rows": 128,
"width": 19,
"cdf_length_min": 19,
"cdf_length_max": 19,
"offset_min": -8,
"offset_max": -8,
"cdf_max": 65536
},
{
"group": "y",
"rows": 64,
"width": 3133,
"cdf_length_min": 5,
"cdf_length_max": 3133,
"offset_min": -1565,
"offset_max": -1,
"cdf_max": 65536
}
],
"z_cdf_group_index": 0,
"y_cdf_group_index": 1,
"images": [
{
"image": "kodim01.png",
"stem": "kodim01",
"bitstream_file": "kodim01.bin",
"bitstream_bytes_stat": 136,
"bitstream_sha256": "771be01642e69c277e6dd6a1b5fe926d45990883e0a2d56a2fca67f5e29b385d",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
81,
52
],
"substream_sha256": [
"7803b0726155984d86876d260c72c7376fd1e24b49fa31e279e6c88b0b0b3e73",
"8b7e01993f42cd08cb08f3f75d2f94e186ea03e772d47a09a58796bf7db33b0b"
],
"vector_file": "kodim01.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 13,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "kodim02.png",
"stem": "kodim02",
"bitstream_file": "kodim02.bin",
"bitstream_bytes_stat": 135,
"bitstream_sha256": "6d158062f4e940a09098fdae516eba541cf1c6c3c4aee39d95031add3bf33d7a",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
90,
42
],
"substream_sha256": [
"a9ab7f345e0733c799da943ac065a21bd407a00e946fab48ec1e1dd8b7bee468",
"7f765eebbaf5dfde47e1d1f70eb8a1407d55f59e0edf5839b378b9c3d7a39dd3"
],
"vector_file": "kodim02.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 13,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "kodim05.png",
"stem": "kodim05",
"bitstream_file": "kodim05.bin",
"bitstream_bytes_stat": 173,
"bitstream_sha256": "65ba06f964e1f726f7a72f2c4e634282d60ed16232747bb4a84c1d73ed8411bc",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
119,
51
],
"substream_sha256": [
"12a97d8cd41b03930fa735d867f31756ba499e2d7220ea10f9b5ea96d1f15de3",
"c30c0be1d858848fcd6833a66bc6c3fae8b3dedcac463a598621794cef4ff894"
],
"vector_file": "kodim05.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 14,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "kodim08.png",
"stem": "kodim08",
"bitstream_file": "kodim08.bin",
"bitstream_bytes_stat": 209,
"bitstream_sha256": "9e1747db3ec84a993e28b52923140948afbf93df483b4f20be93a0f79206ebc4",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
136,
70
],
"substream_sha256": [
"965f5fd268c6f367b998c827f95f090ba2d945d7e4ebe4003fb604c48711cd03",
"19fd4a704675ad1cd3f97aaf2670e7818d465d3fc800a03621dd11e167a77a9f"
],
"vector_file": "kodim08.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 14,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "kodim13.png",
"stem": "kodim13",
"bitstream_file": "kodim13.bin",
"bitstream_bytes_stat": 118,
"bitstream_sha256": "75e13bfca9c13400c62aaab08648fa408a4e4cdb13d02ce3e64767f08728cc64",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
64,
51
],
"substream_sha256": [
"5b1b045e0c850df548bfdc3cf5777bcc1050d226365a5a3ab3dc1cdfd76cd077",
"81b80624237a8796034d94281be0aa3088eebf8124218d7f4a02257473d2fbcb"
],
"vector_file": "kodim13.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 14,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "kodim19.png",
"stem": "kodim19",
"bitstream_file": "kodim19.bin",
"bitstream_bytes_stat": 170,
"bitstream_sha256": "0ac1e2f5981c7afb8c3efd5cc2c14acf4b53d949e4dd2f2701cf377068d62e0e",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
104,
63
],
"substream_sha256": [
"d9f566fbde8f3da32f738004a7e88764cf52d8df04c7905f53c0018057899e6f",
"790c717eb572cb07ac17c4324424293559a127de8299ffd4eaeb02f2d2e4f200"
],
"vector_file": "kodim19.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 13,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "kodim23.png",
"stem": "kodim23",
"bitstream_file": "kodim23.bin",
"bitstream_bytes_stat": 206,
"bitstream_sha256": "1728c415e5a35f9fd501a35369166db8435e3fcfa2fabe99dcd38f21782f6ed7",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
135,
68
],
"substream_sha256": [
"b169be22ee0554ce83b47118534a25066c4dbb21b3c11e16e69c36eb905d485c",
"c3dd848ad555641febdbad1d5cfe7516965362e735eb36172fed096c1f8fa69e"
],
"vector_file": "kodim23.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -2,
"z_q_max": 1,
"y_q_min": -2,
"y_q_max": 2,
"y_index_min": -1,
"y_index_max": 14,
"n_skipped_y_indexes": 1,
"roundtrip_bitexact": true
},
{
"image": "kodim24.png",
"stem": "kodim24",
"bitstream_file": "kodim24.bin",
"bitstream_bytes_stat": 154,
"bitstream_sha256": "9c397ca0212cc6aeaea687c2fc8dc3b88a95a6c153108a056e4fc7254a21b371",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
91,
60
],
"substream_sha256": [
"4d83ea0c3d9f58795068962f02a9eea9d7ad91afaebe0e330c20e95f695e22c1",
"65c7cdc5f581934b21ea8a16aff1a67dd36118c58933997fc776b99c7f317525"
],
"vector_file": "kodim24.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 2,
"y_index_min": 0,
"y_index_max": 14,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
},
{
"image": "image2.webp",
"stem": "image2",
"bitstream_file": "image2.bin",
"bitstream_bytes_stat": 147,
"bitstream_sha256": "258fe68ff7bb9ad41e1b3c7f885c56d051b3d55d313e5e5cc04b4faaf3b181e0",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
88,
56
],
"substream_sha256": [
"60c3ec394f474c060bfe51f4a086ab40d6f409ab4694ff8b02b68f15029116be",
"d874295cab097586ca1da2026a89a694051baf832fe3e4cd52f03fafbdf896c8"
],
"vector_file": "image2.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -3,
"z_q_max": 2,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": -1,
"y_index_max": 16,
"n_skipped_y_indexes": 1,
"roundtrip_bitexact": true
},
{
"image": "images.jpeg",
"stem": "images",
"bitstream_file": "images.bin",
"bitstream_bytes_stat": 128,
"bitstream_sha256": "2ad2224c85c25c394daf0c6e35d0c8fba194b689863f42ebe85a0d67dd632869",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
75,
50
],
"substream_sha256": [
"706a74709539f2d63f0b9b58d78920c9dfe995a804a4af19ded31b0c51f9adde",
"d35ab3320032adaad85343918c5e118fcb3aea20168024e433b608acc6d918ab"
],
"vector_file": "images.gv",
"vector_bytes_stat": 270592,
"n_z_symbols": 2048,
"n_y_symbols_each": 16384,
"z_q_min": -1,
"z_q_max": 1,
"y_q_min": -1,
"y_q_max": 1,
"y_index_min": 0,
"y_index_max": 13,
"n_skipped_y_indexes": 0,
"roundtrip_bitexact": true
}
],
"synthetic": [
{
"name": "z_escape_exact",
"cdf_group": 0,
"n": 640,
"n_filler_each_end": 256,
"bitstream_file": "synth_z_escape_exact.bin",
"bitstream_bytes_stat": 307,
"bitstream_sha256": "43918b9fc2cd685ce619632b83cb00d99b763e7476e2ea08367e1a88d1040c44",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
152,
152
],
"vector_file": "synth_z_escape_exact.gv",
"vector_bytes_stat": 2624,
"expected_decode_sha256": "230e5130f0690bc31122a475f2d04495244ea1c2dcc663c74e9d4182425d9255",
"sym_min": 0,
"sym_max": 9,
"n_skipped": 0,
"roundtrip_exact": true
},
{
"name": "z_escape_mixed",
"cdf_group": 0,
"n": 256,
"n_filler_each_end": 64,
"bitstream_file": "synth_z_escape_mixed.bin",
"bitstream_bytes_stat": 443,
"bitstream_sha256": "fc3088f9608c0c8e56876d0034d1c8ab45ec34c83dd6491c26981836b4f8075f",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
211,
229
],
"vector_file": "synth_z_escape_mixed.gv",
"vector_bytes_stat": 1088,
"expected_decode_sha256": "a428f456dc7f31ecae1c1fc5fed8517bcd585783f03f3e3423fbf3649745501a",
"sym_min": -72,
"sym_max": 138,
"n_skipped": 0,
"roundtrip_exact": true
},
{
"name": "z_dense_normal",
"cdf_group": 0,
"n": 10240,
"n_filler_each_end": 4096,
"bitstream_file": "synth_z_dense_normal.bin",
"bitstream_bytes_stat": 3841,
"bitstream_sha256": "0d2c604369c89081df11ac39a2c3218f3e4bcfe208b32fb9a716ea2ea292be8a",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
1891,
1947
],
"vector_file": "synth_z_dense_normal.gv",
"vector_bytes_stat": 41024,
"expected_decode_sha256": "a293328cba494459297083a0e7c465af5f8ee339c701e5d84bef749ace2d9786",
"sym_min": -8,
"sym_max": 7,
"n_skipped": 0,
"roundtrip_exact": true
},
{
"name": "y_bypass_long",
"cdf_group": 1,
"n": 640,
"n_filler_each_end": 64,
"bitstream_file": "synth_y_bypass_long.bin",
"bitstream_bytes_stat": 2251,
"bitstream_sha256": "faa8b40f9ce682dc10c1cbb6471c684182ed4fa32023ce915513598147f24c99",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
1163,
1085
],
"vector_file": "synth_y_bypass_long.gv",
"vector_bytes_stat": 2624,
"expected_decode_sha256": "f842f136f187644884733ffd83db78647ead2faeec29d2be3d53322159e2589c",
"sym_min": -32000,
"sym_max": 32000,
"n_skipped": 0,
"roundtrip_exact": true
},
{
"name": "y_skip_indexes",
"cdf_group": 1,
"n": 384,
"n_filler_each_end": 128,
"bitstream_file": "synth_y_skip_indexes.bin",
"bitstream_bytes_stat": 160,
"bitstream_sha256": "4144dd2271f54d7ceab9001dbb43601839a08176d576cad15a3fe02502361be7",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
78,
79
],
"vector_file": "synth_y_skip_indexes.gv",
"vector_bytes_stat": 1600,
"expected_decode_sha256": "8e17e97e31d0e687efec4ae1f6bd3d7ddd80d73f6a3bbf1bcf72072062069ae3",
"sym_min": -1564,
"sym_max": 12345,
"n_skipped": 43,
"roundtrip_exact": true
},
{
"name": "y_edges_normal",
"cdf_group": 1,
"n": 1280,
"n_filler_each_end": 512,
"bitstream_file": "synth_y_edges_normal.bin",
"bitstream_bytes_stat": 470,
"bitstream_sha256": "ae336f2000c2f6548e897078cf429e85fa1efd6df7973a6257b6495072e5f8ec",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
207,
260
],
"vector_file": "synth_y_edges_normal.gv",
"vector_bytes_stat": 5184,
"expected_decode_sha256": "ff25d3624640925c557155e83d02fd2fec0b25de8f4b3320fcc6c1db2590c6ac",
"sym_min": -1565,
"sym_max": 1564,
"n_skipped": 0,
"roundtrip_exact": true
},
{
"name": "y_tiny",
"cdf_group": 1,
"n": 130,
"n_filler_each_end": 64,
"bitstream_file": "synth_y_tiny.bin",
"bitstream_bytes_stat": 13,
"bitstream_sha256": "b5df63c5644dc0b7c599a2009de025721c3ff439eed06b427deee1439dc5a84c",
"container_flag": 17,
"container_header_bytes": 2,
"substream_sizes": [
6,
4
],
"vector_file": "synth_y_tiny.gv",
"vector_bytes_stat": 584,
"expected_decode_sha256": "e851be60ef0e9dc7488caaf7ba6e35ecccb933ce6775829d0fbd9dc3010d1d49",
"sym_min": -1,
"sym_max": 0,
"n_skipped": 0,
"roundtrip_exact": true
}
],
"reference_port_selfcheck": {
"ok": 17,
"total": 17
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More