mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
Add ability to send images over lora
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'image_codec_file_store_stub.dart'
|
||||
if (dart.library.io) 'image_codec_file_store_io.dart';
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
export 'image_codec_session_stub.dart'
|
||||
if (dart.library.io) 'image_codec_session_io.dart';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 "2–3".
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user