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
+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_pairing_service_stub.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/path_history_service.dart';
import '../services/app_settings_service.dart';
@@ -248,6 +258,13 @@ class MeshCoreConnector extends ChangeNotifier {
bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService;
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.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now();
@@ -460,6 +477,11 @@ class MeshCoreConnector extends ChangeNotifier {
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey;
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 manufacturerName => _manufacturerName;
String? get firmwareVersionString => _firmwareVersionString;
@@ -996,11 +1018,19 @@ class MeshCoreConnector extends ChangeNotifier {
AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
ImageCodecService? imageCodecService,
ImageChunkTransport? imageTransport,
void Function(ImageChunkOutcome outcome)? onImageChunk,
void Function(int senderPrefix)? onImageSenderPrefix,
}) {
_retryService = retryService;
_pathHistoryService = pathHistoryService;
_appSettingsService = appSettingsService;
_translationService = translationService;
_imageCodecService = imageCodecService;
_imageTransport = imageTransport;
_onImageChunk = onImageChunk;
_onImageSenderPrefix = onImageSenderPrefix;
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
@@ -2575,6 +2605,8 @@ class MeshCoreConnector extends ChangeNotifier {
void _resetConnectionHandshakeState() {
_selfPublicKey = null;
// Partially received images belong to the previous session's sender prefix.
_imageTransport?.reassembler.clear();
_selfName = null;
_selfLatitude = null;
_selfLongitude = null;
@@ -2718,6 +2750,19 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncTimeout = null;
_channelSyncRetries = 0;
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) {
try {
@@ -3550,6 +3595,77 @@ class MeshCoreConnector extends ChangeNotifier {
}, 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> Function() action, {
required String region,
@@ -4231,6 +4347,27 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeChannelMsgRecvV3:
_handleIncomingChannelMessage(frame);
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:
_handleMessageSent(frame);
break;
@@ -4369,6 +4506,14 @@ class MeshCoreConnector extends ChangeNotifier {
_currentCr = reader.readByte();
_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) {
_appDebugLogService?.error(
'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 {
// If we're syncing the queued messages, advance the queue immediately
// 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_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_reply": "Reply",
"chat_addReaction": "Add Reaction",
@@ -2535,6 +2540,17 @@
"radioStats_settingsTile": "Radio stats",
"radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime",
"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_enableSubtitle": "Translate incoming messages and allow pre-send translation.",
"translation_composerTitle": "Translate before sending",
@@ -2716,5 +2732,155 @@
"pathMap_expandPanel": "Expand panel",
"pathMap_noLocation": "No location",
"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'**
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.
///
/// In en, this message translates to:
@@ -7768,6 +7780,72 @@ abstract class AppLocalizations {
/// **'Translation'**
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.
///
/// In en, this message translates to:
@@ -8235,6 +8313,294 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Unlock view from packet'**
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
+240
View File
@@ -1489,6 +1489,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get chat_sendGif => 'Изпрати GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4517,6 +4523,43 @@ class AppLocalizationsBg extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Активирайте превода';
@@ -4800,4 +4843,201 @@ class AppLocalizationsBg extends AppLocalizations {
@override
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
String get chat_sendGif => 'GIF senden';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4526,6 +4532,43 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Aktivieren Sie die Übersetzung';
@@ -4813,4 +4856,201 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get chat_sendGif => 'Send GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4446,6 +4452,43 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Enable translation';
@@ -4727,4 +4770,201 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get chat_sendGif => 'Enviar GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4521,6 +4527,43 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Habilitar la traducción';
@@ -4806,4 +4849,201 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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
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
String get chat_receivedGif => 'Received a GIF';
@@ -4542,6 +4548,43 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Activer la traduction';
@@ -4827,4 +4870,201 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
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
String get chat_receivedGif => 'Received a GIF';
@@ -4515,6 +4521,43 @@ class AppLocalizationsHu extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Fordítás engedélyezése';
@@ -4798,4 +4841,201 @@ class AppLocalizationsHu extends AppLocalizations {
@override
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
String get chat_sendGif => 'Invia GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4525,6 +4531,43 @@ class AppLocalizationsIt extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Abilitare la traduzione';
@@ -4811,4 +4854,201 @@ class AppLocalizationsIt extends AppLocalizations {
@override
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
String get chat_sendGif => 'GIF を送信';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4294,6 +4300,43 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get translation_enableTitle => '翻訳機能を有効にする';
@@ -4569,4 +4612,201 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get chat_sendGif => 'GIF 보내기';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4302,6 +4308,43 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get translation_enableTitle => '번역 기능 활성화';
@@ -4577,4 +4620,201 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get chat_sendGif => 'GIF verzenden';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4503,6 +4509,43 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Activeer vertaling';
@@ -4786,4 +4829,201 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
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
String get chat_receivedGif => 'Received a GIF';
@@ -4542,6 +4548,43 @@ class AppLocalizationsPl extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Włącz tłumaczenie';
@@ -4830,4 +4873,201 @@ class AppLocalizationsPl extends AppLocalizations {
@override
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
String get chat_sendGif => 'Enviar GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4520,6 +4526,43 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Ativar a tradução';
@@ -4802,4 +4845,201 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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
String get chat_sendGif => 'Отправить GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4536,6 +4542,43 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Включить перевод';
@@ -4822,4 +4865,201 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get chat_sendGif => 'Odoslať GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4501,6 +4507,43 @@ class AppLocalizationsSk extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Aktivovať preklad';
@@ -4786,4 +4829,201 @@ class AppLocalizationsSk extends AppLocalizations {
@override
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
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
String get chat_receivedGif => 'Received a GIF';
@@ -4500,6 +4506,43 @@ class AppLocalizationsSl extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Omogočite prevod';
@@ -4787,4 +4830,201 @@ class AppLocalizationsSl extends AppLocalizations {
@override
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
String get chat_sendGif => 'Skicka GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4473,6 +4479,43 @@ class AppLocalizationsSv extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Aktivera översättning';
@@ -4757,4 +4800,201 @@ class AppLocalizationsSv extends AppLocalizations {
@override
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
String get chat_sendGif => 'Надіслати GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4533,6 +4539,43 @@ class AppLocalizationsUk extends AppLocalizations {
@override
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
String get translation_enableTitle => 'Увімкнути переклад';
@@ -4822,4 +4865,201 @@ class AppLocalizationsUk extends AppLocalizations {
@override
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
String get chat_sendGif => '发送 GIF';
@override
String get chat_sendImage => 'Send image';
@override
String get chat_imagePickFailed => 'Couldn\'t open that image';
@override
String get chat_receivedGif => 'Received a GIF';
@@ -4190,6 +4196,43 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get translation_enableTitle => '启用翻译功能';
@@ -4464,4 +4507,201 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
@@ -9,7 +11,13 @@ import 'screens/chrome_required_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
import 'models/image_codec_support.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/message_retry_service.dart';
import 'services/path_history_service.dart';
@@ -26,6 +34,7 @@ import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart';
import 'theme/mesh_theme.dart';
import 'utils/app_logger.dart';
import 'widgets/image_send_codec_binding.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -59,9 +68,52 @@ void main() async {
final uiViewStateService = UiViewStateService();
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();
// ---- 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
appLogger.initialize(
appDebugLogService,
@@ -79,6 +131,8 @@ void main() async {
await chatTextScaleService.initialize();
await translationService.refreshDownloadedModels();
await imageCodecService.refreshDownloadedModels();
await receivedImageStore.load();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
@@ -92,6 +146,12 @@ void main() async {
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
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();
@@ -116,10 +176,107 @@ void main() async {
translationService: translationService,
uiViewStateService: uiViewStateService,
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() {
LicenseRegistry.addLicense(() async* {
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 MessageRetryService retryService;
final PathHistoryService pathHistoryService;
@@ -154,6 +311,9 @@ class MeshCoreApp extends StatelessWidget {
final TranslationService translationService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
final ImageCodecService imageCodecService;
final ReceivedImageStore receivedImageStore;
final ImageStreamReassembler imageReassembler;
const MeshCoreApp({
super.key,
@@ -169,24 +329,86 @@ class MeshCoreApp extends StatelessWidget {
required this.translationService,
required this.uiViewStateService,
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
Widget build(BuildContext context) {
final connector = widget.connector;
final storage = widget.storage;
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: translationService),
ChangeNotifierProvider.value(value: uiViewStateService),
ChangeNotifierProvider.value(value: widget.retryService),
ChangeNotifierProvider.value(value: widget.pathHistoryService),
ChangeNotifierProvider.value(value: widget.appSettingsService),
ChangeNotifierProvider.value(value: widget.bleDebugLogService),
ChangeNotifierProvider.value(value: widget.appDebugLogService),
ChangeNotifierProvider.value(value: widget.chatTextScaleService),
ChangeNotifierProvider.value(value: widget.translationService),
ChangeNotifierProvider.value(value: widget.uiViewStateService),
Provider.value(value: storage),
ChangeNotifierProvider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
ChangeNotifierProvider.value(value: widget.mapTileCacheService),
ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
ChangeNotifierProvider.value(value: widget.imageCodecService),
ChangeNotifierProvider.value(value: widget.receivedImageStore),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
+89
View File
@@ -1,3 +1,4 @@
import 'image_codec_support.dart';
import 'translation_support.dart';
enum UnitSystem { metric, imperial }
@@ -118,6 +119,29 @@ class AppSettings {
final String tcpServerAddress;
final int tcpServerPort;
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 autoTranslateIncomingMessages;
final String? translationTargetLanguageCode;
@@ -128,6 +152,16 @@ class AppSettings {
final List<Cyr2LatProfile> cyr2latProfiles;
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 {
final apiKey = mapTileApiKey?.trim();
if (apiKey == null || apiKey.isEmpty) {
@@ -187,6 +221,13 @@ class AppSettings {
this.tcpServerAddress = '',
this.tcpServerPort = 0,
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.autoTranslateIncomingMessages = true,
this.translationTargetLanguageCode,
@@ -199,6 +240,8 @@ class AppSettings {
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {},
imageCodecDownloadedModels =
imageCodecDownloadedModels ?? const [],
translationDownloadedModels = translationDownloadedModels ?? const [],
cyr2latProfiles =
cyr2latProfiles ??
@@ -254,6 +297,15 @@ class AppSettings {
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
'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,
'auto_translate_incoming_messages': autoTranslateIncomingMessages,
'translation_target_language_code': translationTargetLanguageCode,
@@ -343,6 +395,23 @@ class AppSettings {
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
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,
autoTranslateIncomingMessages:
json['auto_translate_incoming_messages'] as bool? ?? true,
@@ -439,6 +508,13 @@ class AppSettings {
String? tcpServerAddress,
int? tcpServerPort,
bool? jumpToOldestUnread,
bool? imageMessagesEnabled,
bool? imageProcessAutomatically,
bool? imageCodecEnabled,
Object? imageCodecSelectedModelId = _unset,
Object? imageCodecModelSourceUrl = _unset,
int? imageCodecRatePoint,
List<ImageCodecModelRecord>? imageCodecDownloadedModels,
bool? translationEnabled,
bool? autoTranslateIncomingMessages,
Object? translationTargetLanguageCode = _unset,
@@ -506,6 +582,19 @@ class AppSettings {
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
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,
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 '../l10n/l10n.dart';
import '../models/app_settings.dart';
import '../models/image_codec_support.dart';
import '../models/translation_support.dart';
import '../services/app_settings_service.dart';
import '../services/image_codec_service.dart';
import '../services/map_tile_cache_service.dart';
import '../services/notification_service.dart';
import '../services/translation_service.dart';
@@ -19,7 +21,16 @@ import '../helpers/snack_bar_builder.dart';
import 'map_cache_screen.dart';
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
Widget build(BuildContext context) {
@@ -598,10 +609,206 @@ class AppSettingsScreen extends StatelessWidget {
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(
BuildContext context,
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:convert';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:provider/provider.dart';
@@ -22,10 +24,16 @@ import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../models/image_codec_support.dart' show aeicRatePointForUi;
import '../models/translation_support.dart';
import '../services/app_settings_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 '../utils/lora_airtime.dart';
import '../widgets/received_image_message.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.dart';
@@ -33,6 +41,9 @@ import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.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_status_icon.dart';
import '../widgets/radio_stats_entry.dart';
@@ -41,6 +52,7 @@ import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'app_settings_screen.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import 'region_management_screen.dart';
@@ -71,6 +83,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool _isLoadingOlder = 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;
DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false;
@@ -373,8 +395,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel);
final imageRows = _receivedImageRows(context);
if (messages.isEmpty) {
if (messages.isEmpty && imageRows.isEmpty) {
return EmptyState(
icon: widget.channel.isPublicChannel
? Icons.public
@@ -390,16 +413,22 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
// Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList();
final itemCount =
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Images are not ChannelMessages: they arrive as GRP_DATA
// chunks and are owned by ReceivedImageStore, so the two
// sources are merged here in timestamp order and the list
// 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
// unbounded growth.
final liveIds = reversedMessages
.map((m) => m.messageId)
.toSet();
final liveIds = reversedRows.map((r) => r.id).toSet();
_messageKeys.removeWhere((id, _) => !liveIds.contains(id));
// 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.
final seenIds = <String>{};
final keyedIndices = <int>{};
for (var i = 0; i < reversedMessages.length; i++) {
if (seenIds.add(reversedMessages[i].messageId)) {
for (var i = 0; i < reversedRows.length; i++) {
if (seenIds.add(reversedRows[i].id)) {
keyedIndices.add(i);
}
}
@@ -447,12 +476,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
);
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
final row = reversedRows[index];
final GlobalKey messageKey;
if (keyedIndices.contains(messageIndex)) {
if (keyedIndices.contains(index)) {
messageKey = _messageKeys.putIfAbsent(
message.messageId,
row.id,
GlobalKey.new,
);
} else {
@@ -460,7 +488,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
row.id == _unreadDividerMessageId;
return Container(
key: messageKey,
child: Builder(
@@ -469,10 +497,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.select<ChatTextScaleService, double>(
(service) => service.scale,
);
final bubble = _buildMessageBubble(
message,
textScale,
);
final message = row.message;
final bubble = message != null
? _buildMessageBubble(message, textScale)
: _buildImageBubble(row.image!, textScale);
if (isUnreadAnchor) {
return Column(
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) {
showModalBottomSheet(
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() {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
@@ -1122,6 +1608,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return _buildReplyBanner(textScale);
},
),
if (_imageSendTotal > 0) _buildImageSendProgress(scheme),
Container(
decoration: BoxDecoration(
color: scheme.surface,
@@ -1146,6 +1633,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
languageCode: settings.translationTargetLanguageCode,
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(
child: ValueListenableBuilder<TextEditingValue>(
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 '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 {
final Contact contact;
final int initialUnreadCount;
@@ -480,6 +488,9 @@ class _ChatScreenState extends State<ChatScreen> {
languageCode: settings.translationTargetLanguageCode,
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(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
+52
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../models/app_settings.dart';
import '../models/image_codec_support.dart';
import '../models/translation_support.dart';
import '../storage/prefs_manager.dart';
import '../utils/app_logger.dart';
@@ -280,6 +281,57 @@ class AppSettingsService extends ChangeNotifier {
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 {
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),
),
],
),
),
);
}
}