diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c8028e0d..8aa73ce6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -43,9 +43,22 @@ android { // arguments += listOf("-DANDROID_STL=c++_shared") // } // } - // ndk { - // abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64") - // } + // arm64-v8a only, deliberately. + // + // * ONNX Runtime (flutter_onnxruntime, used by the AEIC-SE image codec) + // ships a per-ABI .so. arm64-v8a alone costs ~18 MB of APK; a + // universal APK carrying armeabi-v7a and x86_64 as well costs ~56 MB. + // * llamadart only declares android-arm64 and android-x64 backends in + // pubspec.yaml's `hooks.user_defines`, so an armeabi-v7a build already + // has no translation backend at all. + // * The image codec needs ~2.7 GiB peak resident, which no 32-bit + // address space can provide regardless of ABI. + // + // Consequence: this APK will not install on 32-bit-only ARM devices or on + // x86_64 emulators. For emulator work, temporarily add "x86_64" here. + ndk { + abiFilters += listOf("arm64-v8a") + } } signingConfigs { @@ -67,6 +80,13 @@ android { } else { signingConfigs.getByName("debug") } + // ONNX Runtime resolves its Java classes from native code by name. + // Without these rules R8 renames them and the process SIGABRTs with + // "java_class == null" the instant the codec runs a model. + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..aac0378c --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,19 @@ +# ONNX Runtime looks its Java classes up from native code with FindClass / +# GetMethodID, by literal name. R8 renames them, the lookup returns null, and +# the process dies with: +# +# JNI DETECTED ERROR IN APPLICATION: java_class == null +# at art::JNI::GetMethodID +# from convertToTensorInfo -> Java_ai_onnxruntime_OrtSession_run +# +# It is a hard SIGABRT in native code, so nothing in Dart can catch it: the app +# vanishes the moment the codec touches the model. Keep the whole package — +# these are the types the native layer reflects on to build tensors and read +# results back. +-keep class ai.onnxruntime.** { *; } +-keepclassmembers class ai.onnxruntime.** { *; } +-dontwarn ai.onnxruntime.** + +# The Flutter plugin's platform-channel handler, reached the same way. +-keep class com.masicai.flutteronnxruntime.** { *; } +-dontwarn com.masicai.flutteronnxruntime.** diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 00d9efae..16b71bfe 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -37,6 +37,8 @@ This app uses Bluetooth to communicate with MeshCore devices. NSCameraUsageDescription This app uses the camera to scan QR codes for joining communities. + NSPhotoLibraryUsageDescription + This app lets you pick a photo to compress and send over the mesh. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0ad5302e..cd11340b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 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 sendImageChunks( + List 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.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 _runScopedChannelSend( Future 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). diff --git a/lib/helpers/image_send_flow.dart b/lib/helpers/image_send_flow.dart new file mode 100644 index 00000000..8c5f84d0 --- /dev/null +++ b/lib/helpers/image_send_flow.dart @@ -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 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, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index acc18eb0..d1c79c6b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 02132ecd..c3fc41fc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index de33175a..4b314d58 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 857ad7ce..e50cf9af 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 05de4f1f..83337bd7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4a7db97a..8889ec49 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 104c0d15..b6d1a85c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 2477885d..72665c96 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 6cbbea43..919f656c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 739b74a0..3d3f3783 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 425540bb..f30a9584 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a63fa2a5..6ed73e0a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 1c0a82f5..79bcc671 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index f251d307..9b0cfea1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index fec259e5..6912aa10 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 910d16fc..99927843 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index ecec8319..f8fdf85b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 8b2a09f8..dfb24cb6 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 45d4c7c8..899ee0b5 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 33875d89..6d73146d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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'; + } } diff --git a/lib/main.dart b/lib/main.dart index 7cabda17..786566de 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( + [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 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 load() async { + // AppSettingsService.loadSettings() already ran in main(). + } + + @override + Future 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 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 + 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( builder: (context, settingsService, child) { diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 82d570c7..bb23a451 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -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 imageCodecDownloadedModels; + final bool translationEnabled; final bool autoTranslateIncomingMessages; final String? translationTargetLanguageCode; @@ -128,6 +152,16 @@ class AppSettings { final List 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? 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?) + ?.map( + (entry) => ImageCodecModelRecord.fromJson( + Map.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? 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, diff --git a/lib/models/image_codec_support.dart b/lib/models/image_codec_support.dart new file mode 100644 index 00000000..b6d5b4e9 --- /dev/null +++ b/lib/models/image_codec_support.dart @@ -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 kImageCodecLatentShape = [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 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 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 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': { + for (final entry in assetRoles.entries) entry.key: entry.value.name, + }, + 'bundle_version': bundleVersion, + }; + } + + factory ImageCodecModelRecord.fromJson(Map 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 + ? { + 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? assetFileNames, + Map? 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, + + /// `.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 (`.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 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(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//resolve/main/?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 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 downloadedModels; + + const ImageCodecPreferences({ + this.enabled = false, + this.selectedModelId, + this.modelSourceUrl, + this.ratePoint = 4, // AeicRatePoint.ft32 + this.downloadedModels = const [], + }); + + AeicRatePoint get aeicRatePoint => parseAeicRatePoint(ratePoint); + + Map 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 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) + ImageCodecModelRecord.fromJson(entry), + ] + : const [], + ); + } + + static const Object _unset = Object(); + + ImageCodecPreferences copyWith({ + bool? enabled, + Object? selectedModelId = _unset, + Object? modelSourceUrl = _unset, + int? ratePoint, + List? 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, + ); + } +} diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 729f510c..e972c834 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -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(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(); + } 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 _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; +} diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 2e7c3b68..ccceb704 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -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 { 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 { child: Consumer( 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 { ); } - // 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 { // no two widgets share one GlobalKey. final seenIds = {}; final keyedIndices = {}; - 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 { ), ); } - 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 { } final isUnreadAnchor = _unreadDividerMessageId != null && - message.messageId == _unreadDividerMessageId; + row.id == _unreadDividerMessageId; return Container( key: messageKey, child: Builder( @@ -469,10 +497,10 @@ class _ChannelChatScreenState extends State { .select( (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 { ); } + /// The image codec, or null when it is not registered (screen tests). + ImageCodecService? get _imageCodec { + try { + return context.read(); + } 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().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 _showImageSendPreview() async { + final codec = _imageCodec; + if (codec == null) return; + final connector = context.read(); + + // 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 _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 _registerOutgoingImage({ + required ImageSendPreviewResult result, + required ImageChunkSet chunkSet, + required int senderPrefix, + required Uint8List sourceBytes, + }) async { + final ReceivedImageStore store; + try { + store = context.read(); + } 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 _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 _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(); + } 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( + 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(); + 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 { ); } + /// "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(); final maxBytes = maxChannelMessageBytes(connector.selfName); @@ -1122,6 +1608,7 @@ class _ChannelChatScreenState extends State { return _buildReplyBanner(textScale); }, ), + if (_imageSendTotal > 0) _buildImageSendProgress(scheme), Container( decoration: BoxDecoration( color: scheme.surface, @@ -1146,6 +1633,17 @@ class _ChannelChatScreenState extends State { 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( 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, + ); +} diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index bfe5a381..bafa5f25 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -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 { 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( valueListenable: _textController, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index e695ba99..4f3a06ed 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -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 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 setImageProcessAutomatically(bool value) async { + await updateSettings(_settings.copyWith(imageProcessAutomatically: value)); + } + + // ---- neural image codec (AEIC-SE) --------------------------------------- + + Future setImageCodecEnabled(bool value) async { + await updateSettings(_settings.copyWith(imageCodecEnabled: value)); + } + + Future setImageCodecSelectedModelId(String? value) async { + await updateSettings(_settings.copyWith(imageCodecSelectedModelId: value)); + } + + Future setImageCodecModelSourceUrl(String? value) async { + await updateSettings(_settings.copyWith(imageCodecModelSourceUrl: value)); + } + + /// [value] is an [AeicRatePoint.wireValue], not an enum index. + Future setImageCodecRatePoint(int value) async { + await updateSettings(_settings.copyWith(imageCodecRatePoint: value)); + } + + Future setImageCodecDownloadedModels( + List 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 setImageCodecPreferences(ImageCodecPreferences value) async { + await updateSettings( + _settings.copyWith( + imageCodecEnabled: value.enabled, + imageCodecSelectedModelId: value.selectedModelId, + imageCodecModelSourceUrl: value.modelSourceUrl, + imageCodecRatePoint: value.ratePoint, + imageCodecDownloadedModels: value.downloadedModels, + ), + ); + } + Future setTranslationEnabled(bool value) async { await updateSettings(_settings.copyWith(translationEnabled: value)); } diff --git a/lib/services/entropy_tables.dart b/lib/services/entropy_tables.dart new file mode 100644 index 00000000..b02f0793 --- /dev/null +++ b/lib/services/entropy_tables.dart @@ -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 magic = [ + 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 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 groups = []; + 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}', + ); + } + } +} diff --git a/lib/services/image_chunk_transport.dart b/lib/services/image_chunk_transport.dart new file mode 100644 index 00000000..d4ad7c92 --- /dev/null +++ b/lib/services/image_chunk_transport.dart @@ -0,0 +1,1330 @@ +/// Chunked image transport over MeshCore `PAYLOAD_TYPE_GRP_DATA` (0x06), +/// carried by the companion command `CMD_SEND_CHANNEL_DATA` (62) and received +/// as `RESP_CODE_CHANNEL_DATA_RECV` (27). +/// +/// This file is the SINGLE SOURCE OF TRUTH for the on-air chunk framing: the +/// blob size, header layout, per-chunk capacities and the XOR parity scheme. +/// `lib/utils/lora_airtime.dart` re-exports the constants below rather than +/// declaring rivals, so the airtime estimator cannot drift from the chunker; +/// anything else that needs chunk geometry must do the same. +/// +/// Everything above the "protocol glue" section is pure Dart: no Flutter, no +/// BLE, no connector. It is directly unit-testable. +/// +/// ## Wire format +/// +/// One MeshCore GRP_DATA blob per chunk, at most [kImageChunkBlobBytes] bytes: +/// +/// ``` +/// off size field +/// 0 2 sender_prefix selfPublicKey[0..1] — the firmware supplies no +/// sender identity on RESP_CODE_CHANNEL_DATA_RECV, +/// so it must be in-band in EVERY chunk (chunk 0 +/// may be the one that is lost). +/// 2 1 img_id uint8, random-ish per image +/// 3 1 idx<<4 | total idx 0..15, total 1..15. +/// idx == total => XOR parity chunk. +/// 4 .. body see below +/// ``` +/// +/// Data chunk body: +/// * chunk 0: `[meta]` + image bytes. `meta` is [ImageStreamMetadata] packed +/// as `aspect(4) | resolution(2) | rate(2)`. The aspect nibble names the +/// source photo's shape so the receiver can undo the stretch into the +/// square — see [kImageAspectCodes]. It costs no extra bytes. +/// * others : image bytes +/// * at most [kImageChunkBodyBytes] bytes. +/// +/// Parity chunk body: +/// * `[len_xor]` + XOR of every data chunk body, each zero-padded to +/// [kImageChunkBodyBytes]. +/// * `len_xor` is the XOR of every data chunk's body LENGTH. It is what makes +/// recovery self-describing: a lost chunk's length is recovered as +/// `len_xor ^ XOR(lengths of the bodies that did arrive)`, so the last +/// (short) chunk can be rebuilt without transmitting a total length. +/// +/// Body capacity is [kImageChunkBodyBytes] = blob(163) - header(4) - the +/// parity chunk's 1 length byte, so that the parity chunk itself still fits in +/// a single blob. The cost is exactly one wasted byte in each data chunk; that +/// is the price of self-describing single-loss recovery. +/// +/// ## Integrity +/// +/// There is deliberately NO app-level checksum. Two layers below already cover +/// corruption of a delivered chunk: the LoRa PHY CRCs every packet (`setCRC(1)` +/// in each radio driver, and a failing packet is dropped by the modem), and +/// MeshCore verifies a 2-byte HMAC-SHA256 per packet and rejects on mismatch +/// (`Utils::MACThenDecrypt` returns 0 for a bad tag). What no lower layer can +/// see is a cross-image MERGE — two senders colliding on senderPrefix + imgId + +/// channel inside one TTL, ~1/65536 per concurrent pair — which produces a +/// corrupt image rather than a clean failure. That residual risk is accepted: +/// paying 2 bytes to detect it cost far more than it was worth, because it +/// pushed the measured ft32 mean past the single-chunk capacity. +/// +/// ## Capacity check against measured codec output +/// chunk 0: 157 data bytes (158 body - 1 meta), other chunks: 158. +/// ft32 mean 155.8 B -> 1 chunk (2 packets with parity). +/// ft32 max 209 B -> 2 chunks (157+158 = 315). +/// ft16 max 409 B -> 3 chunks (157+158+158 = 473). +/// Design goals (ft32 1-2 chunks, ft16 2-3 chunks) hold. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../widgets/image_send_codec_binding.dart' show ImageCodecRatePoint; + +// --------------------------------------------------------------------------- +// Protocol constants (firmware-derived; see the transport investigation) +// --------------------------------------------------------------------------- + +/// `CMD_SEND_CHANNEL_DATA` — companion command that emits a GRP_DATA packet. +const int cmdSendChannelData = 62; + +/// `RESP_CODE_CHANNEL_DATA_RECV` — inbound frame carrying a GRP_DATA blob. +const int respCodeChannelDataRecv = 27; + +/// `OUT_PATH_UNKNOWN` — request flood routing. +const int outPathUnknown = 0xFF; + +/// Our application data type. `0x0000` is rejected by the firmware. +const int dataTypeAeicImage = 0xAE1C; + +/// Maximum blob we will ever put in one GRP_DATA packet. +/// +/// Binding limits, smallest first: +/// * app `maxFrameSize` 172 - 9 byte RESP header = **163** <- used +/// * BLE ATT_MTU 176 - 3 notify - 9 resp header = 164 +/// * radio `MAX_GROUP_DATA_LENGTH` 184 - 16 - 3 = 165 +/// * serial `MAX_CHANNEL_DATA_LENGTH` 176 - 9 = 167 +const int kImageChunkBlobBytes = 163; + +/// Per-chunk header: sender_prefix(2) + img_id(1) + idx/total(1). +const int kImageChunkHeaderBytes = 4; + +/// The parity chunk spends one body byte on the XOR of the data body lengths. +const int kImageParityLengthBytes = 1; + +/// Maximum body bytes in any chunk (data or parity payload region). +const int kImageChunkBodyBytes = + kImageChunkBlobBytes - kImageChunkHeaderBytes - kImageParityLengthBytes; + +/// The single [ImageStreamMetadata] byte carried by chunk 0. +const int kImageChunkMetadataBytes = 1; + +/// Body bytes of chunk 0 that are NOT image data: just the metadata byte. +/// +/// A CRC-16 briefly lived here. It was removed: the LoRa PHY already CRCs every +/// packet (`setCRC(1)` in every radio driver) and MeshCore verifies a 2-byte +/// HMAC per packet (`Utils::MACThenDecrypt`, which returns 0 on mismatch), so a +/// corrupted chunk never reaches this layer. The only thing an app-level CRC +/// added was detection of a cross-image merge (two senders colliding on +/// senderPrefix + imgId + channel inside one TTL, ~1/65536), and it cost 2 of +/// chunk 0's bytes -- which pushed the measured ft32 mean of 155.8 B past the +/// single-chunk capacity and turned half of all images from 1 packet into 3. +const int kImageChunkZeroMetadataBytes = kImageChunkMetadataBytes; + +/// Image bytes carried by chunk 0. +const int kImageChunkFirstCapacity = + kImageChunkBodyBytes - kImageChunkZeroMetadataBytes; + +/// Image bytes carried by every chunk after chunk 0. +const int kImageChunkCapacity = kImageChunkBodyBytes; + +/// `total` is 4 bits and must be >= 1, so at most 15 data chunks. +const int kImageMaxDataChunks = 15; + +/// Largest image bitstream this framing can carry. +const int kImageMaxPayloadBytes = + kImageChunkFirstCapacity + (kImageMaxDataChunks - 1) * kImageChunkCapacity; + +/// Bytes of the sender public key repeated in every chunk. +const int kImageSenderPrefixBytes = 2; + +/// How long a partially received image is kept before it is abandoned. +const Duration kImageReassemblyTtl = Duration(seconds: 60); + +// --------------------------------------------------------------------------- +// Rate point <-> wire code +// --------------------------------------------------------------------------- + +/// Wire code for `ft32` in the low nibble of the chunk-0 metadata byte. +const int kImageRateWireStandard = 0; + +/// Wire code for `ft16`. Reserved: ft16 is NOT a shipping rate point, but the +/// code stays allocated so an ft32-only build and a future ft16-capable build +/// agree on the nibble. +const int kImageRateWireHigh = 1; + +/// Number of rate codes this build knows how to name. +const int kImageRateWireCodeCount = 2; + +/// Maps a UI rate point to the wire code written into the metadata byte. +/// +/// THIS IS NOT `AeicRatePoint.wireValue`. There are two rate enumerations in +/// this codebase and they do not share an ordinal space: +/// +/// * `AeicRatePoint` (`lib/models/image_codec_support.dart`) is +/// `{ft2, ft4, ft8, ft16, ft32}` and its `wireValue` is that ordinal, 0..4. +/// It selects a MODEL from the registry. `ft32` is 4 there. +/// * [ImageCodecRatePoint] is `{standard, high}`, 0..1, and is what the +/// chunk-0 nibble names. `ft32` is [kImageRateWireStandard] == 0 here. +/// +/// Writing an `AeicRatePoint.wireValue` into the nibble would put 4 on the wire +/// for the only shipping rate, which [imageRatePointFromWireCode] rejects +/// outright rather than silently landing on a rate point that decodes to the +/// wrong model. The switch is exhaustive on purpose: adding a rate point is a +/// compile error here, not a silent ordinal shift. +int imageRateWireCode(ImageCodecRatePoint rate) { + switch (rate) { + case ImageCodecRatePoint.standard: + return kImageRateWireStandard; + case ImageCodecRatePoint.high: + return kImageRateWireHigh; + } +} + +/// Inverse of [imageRateWireCode]; null for a code this build cannot decode. +ImageCodecRatePoint? imageRatePointFromWireCode(int code) { + switch (code) { + case kImageRateWireStandard: + return ImageCodecRatePoint.standard; + case kImageRateWireHigh: + return ImageCodecRatePoint.high; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Stream metadata (the single byte carried by chunk 0) +// --------------------------------------------------------------------------- + +/// Square sizes addressable by the 2-bit resolution code in the metadata byte. +/// +/// Index == wire code. 512 is code 0 because it is the only size the current +/// decoder supports; the rest exist so a future model can be signalled without +/// a format change. +const List kImageResolutionCodes = [512, 256, 768, 1024]; + +/// Source aspect ratios addressable by the 4-bit aspect code, as `w:h`. +/// +/// The codec encodes a 512x512 SQUARE — the whole frame stretched to fit, not a +/// crop, so nothing outside the frame is discarded. That stretch is not +/// invertible from the pixels alone, so the sender names the original shape +/// here and the receiver letterboxes back to it. +/// +/// This costs ZERO extra bytes: the metadata byte previously spent 4 bits on a +/// resolution with 4 legal values and 4 bits on a rate with 2, so the byte was +/// repacked (2 + 2 + 4) rather than widened. Adding a byte would have cost far +/// more than it looks — the measured ft32 mean of 155.8 B sits just under +/// chunk 0's 157-byte capacity, so one more byte pushes a chunk of the +/// distribution from 1 data chunk to 2, i.e. from 2 packets on air to 3. That +/// is exactly what the CRC did before it was removed. +/// +/// Index == wire code. Code 0 is 1:1 (no letterboxing). Code 15 means "not one +/// of these" and is rendered square, unstretched — a graceful degradation, not +/// an error. The rest are the shapes phone cameras actually produce, so the +/// common cases restore EXACTLY rather than approximately. +const List> kImageAspectCodes = >[ + [1, 1], // 0 square + [5, 4], // 1 landscape + [4, 3], // 2 + [3, 2], // 3 + [16, 10], // 4 + [16, 9], // 5 + [2, 1], // 6 + [21, 9], // 7 + [4, 5], // 8 portrait + [3, 4], // 9 + [2, 3], // 10 + [10, 16], // 11 + [9, 16], // 12 + [1, 2], // 13 + [9, 21], // 14 + [1, 1], // 15 unknown -> render square +]; + +/// Wire code for "shape unknown"; the receiver renders it square. +const int kImageAspectUnknown = 15; + +/// The [kImageAspectCodes] entry closest to `width / height`, in log space so +/// that 4:3 and 3:4 are equally far from square. +/// +/// Returns [kImageAspectUnknown] for a ratio outside roughly 21:9..9:21, rather +/// than snapping a panorama onto 21:9 and letterboxing it wrongly. +int imageAspectCodeFor(int width, int height) { + if (width <= 0 || height <= 0) return kImageAspectUnknown; + final target = math.log(width / height); + var best = kImageAspectUnknown; + var bestErr = double.infinity; + for (var i = 0; i < kImageAspectCodes.length; i++) { + if (i == kImageAspectUnknown) continue; // duplicate of 1:1 + final e = kImageAspectCodes[i]; + final err = (math.log(e[0] / e[1]) - target).abs(); + if (err < bestErr) { + bestErr = err; + best = i; + } + } + // Half a step between 21:9 and the next ratio out; beyond that we would be + // asserting a shape the sender never had. + return bestErr <= 0.18 ? best : kImageAspectUnknown; +} + +/// Contents of the chunk-0 metadata byte: `resolution_code << 4 | rate_index`. +class ImageStreamMetadata { + /// Codec rate point. Reuses the UI enum — there is deliberately no second + /// rate-point enum in this codebase. + final ImageCodecRatePoint rate; + + /// Square edge length in pixels the sender encoded at. + final int squareSize; + + /// Index into [kImageAspectCodes]: the shape the source photo was BEFORE it + /// was stretched into the square. The receiver letterboxes back to it. + final int aspectCode; + + /// Original `width / height`, or 1.0 when the sender said "unknown". + double get aspectRatio { + final e = kImageAspectCodes[aspectCode & 0x0F]; + return e[0] / e[1]; + } + + /// True when the image should be rendered square, unstretched. + bool get isSquare => aspectCode == 0 || aspectCode == kImageAspectUnknown; + + const ImageStreamMetadata({ + required this.rate, + this.squareSize = 512, + this.aspectCode = 0, + }); + + /// Encodes to the single wire byte. + /// + /// Throws [ArgumentError] if [squareSize] is not one of + /// [kImageResolutionCodes]. + int encode() { + final code = kImageResolutionCodes.indexOf(squareSize); + if (code < 0) { + throw ArgumentError.value( + squareSize, + 'squareSize', + 'not representable; must be one of $kImageResolutionCodes', + ); + } + // Repacked: aspect(4) | resolution(2) | rate(2). Lossless for every value + // the old 4+4 layout could express, because resolution has 4 legal codes + // and rate has 2. + return ((aspectCode & 0x0F) << 4) | + ((code & 0x03) << 2) | + (imageRateWireCode(rate) & 0x03); + } + + /// Decodes the wire byte, or returns null if it names a rate point or + /// resolution this build does not know. + /// + /// Deliberately never falls back to a default rate: an unknown code means the + /// sender is running a format we cannot decode, and guessing `standard` would + /// hand the wrong model a bitstream it will happily turn into garbage. The + /// caller surfaces null as [ImageChunkStatus.unsupportedFormat]. + static ImageStreamMetadata? decode(int byte) { + final rate = imageRatePointFromWireCode(byte & 0x03); + final code = (byte >> 2) & 0x03; + final aspect = (byte >> 4) & 0x0F; + if (rate == null) return null; + if (code >= kImageResolutionCodes.length) return null; + return ImageStreamMetadata( + rate: rate, + squareSize: kImageResolutionCodes[code], + aspectCode: aspect, + ); + } + + @override + String toString() => + 'ImageStreamMetadata(${rate.name}, ${squareSize}px, ' + 'aspect ${kImageAspectCodes[aspectCode & 0x0F].join(":")})'; + + @override + bool operator ==(Object other) => + other is ImageStreamMetadata && + other.rate == rate && + other.squareSize == squareSize && + other.aspectCode == aspectCode; + + @override + int get hashCode => Object.hash(rate, squareSize, aspectCode); +} + +// --------------------------------------------------------------------------- +// Chunk header +// --------------------------------------------------------------------------- + +/// Parsed 4-byte chunk header. +class ImageChunkHeader { + /// First [kImageSenderPrefixBytes] bytes of the sender's public key. + final int senderPrefix; + + final int imgId; + + /// 0-based chunk index. Equals [total] for the parity chunk. + final int index; + + /// Number of DATA chunks in this image (parity not counted). 1..15. + final int total; + + const ImageChunkHeader({ + required this.senderPrefix, + required this.imgId, + required this.index, + required this.total, + }); + + bool get isParity => index == total; + + @override + String toString() => 'ImageChunkHeader(sender: 0x' + '${senderPrefix.toRadixString(16).padLeft(4, '0')}, img: $imgId, ' + 'idx: $index/$total${isParity ? ' parity' : ''})'; +} + +/// Packs [senderPrefix] (2 bytes, big-endian as an int) into a header. +Uint8List _writeHeader(int senderPrefix, int imgId, int index, int total) { + return Uint8List.fromList([ + (senderPrefix >> 8) & 0xFF, + senderPrefix & 0xFF, + imgId & 0xFF, + ((index & 0x0F) << 4) | (total & 0x0F), + ]); +} + +/// Reads the first two bytes of a public key as the sender prefix integer. +/// +/// Returns null when [publicKey] is too short to identify a sender. +int? senderPrefixFromKey(List? publicKey) { + if (publicKey == null || publicKey.length < kImageSenderPrefixBytes) { + return null; + } + return ((publicKey[0] & 0xFF) << 8) | (publicKey[1] & 0xFF); +} + +/// Parses the header of a received blob, or null if it cannot be a chunk. +ImageChunkHeader? parseImageChunkHeader(Uint8List blob) { + if (blob.length < kImageChunkHeaderBytes) return null; + if (blob.length > kImageChunkBlobBytes) return null; + final total = blob[3] & 0x0F; + if (total == 0) return null; + final index = (blob[3] >> 4) & 0x0F; + if (index > total) return null; // index == total is the parity chunk + return ImageChunkHeader( + senderPrefix: ((blob[0] & 0xFF) << 8) | (blob[1] & 0xFF), + imgId: blob[2] & 0xFF, + index: index, + total: total, + ); +} + +// --------------------------------------------------------------------------- +// Chunking (send side) +// --------------------------------------------------------------------------- + +/// Number of DATA chunks needed for a [payloadBytes]-long bitstream. +/// +/// A zero-length payload still needs one chunk: chunk 0 carries the metadata +/// byte, and a receiver must be able to observe an empty image rather than +/// nothing at all. +int imageDataChunkCount(int payloadBytes) { + final n = math.max(payloadBytes, 0); + if (n <= kImageChunkFirstCapacity) return 1; + final remaining = n - kImageChunkFirstCapacity; + return 1 + (remaining / kImageChunkCapacity).ceil(); +} + +/// The blobs of one image, ready to hand to `CMD_SEND_CHANNEL_DATA`. +class ImageChunkSet { + /// Every blob in send order; the parity blob, if any, is last. + final List blobs; + + /// Number of data chunks (excludes parity). + final int dataChunkCount; + + final bool hasParity; + + final int imgId; + + final int senderPrefix; + + const ImageChunkSet({ + required this.blobs, + required this.dataChunkCount, + required this.hasParity, + required this.imgId, + required this.senderPrefix, + }); + + int get totalBytes => blobs.fold(0, (a, b) => a + b.length); +} + +/// Splits an encoded image bitstream into chunk blobs. +/// +/// Chunk 0's body opens with the metadata byte; the rest is image bytes. +/// +/// [senderPrefix] must come from the local node's public key (see +/// [senderPrefixFromKey]); receivers key reassembly on it and drop chunks whose +/// prefix equals their own. +/// +/// [parity] appends one XOR parity chunk (GRP_DATA is unacknowledged, so this +/// buys recovery of exactly one lost chunk). +/// +/// Throws [ArgumentError] when [payload] exceeds [kImageMaxPayloadBytes]. +ImageChunkSet buildImageChunks({ + required Uint8List payload, + required ImageStreamMetadata metadata, + required int senderPrefix, + required int imgId, + bool parity = true, +}) { + if (payload.length > kImageMaxPayloadBytes) { + throw ArgumentError.value( + payload.length, + 'payload', + 'exceeds kImageMaxPayloadBytes ($kImageMaxPayloadBytes)', + ); + } + final total = imageDataChunkCount(payload.length); + + // Build the bodies first; parity is a pure function of them. + final bodies = []; + var offset = 0; + for (var i = 0; i < total; i++) { + final capacity = i == 0 ? kImageChunkFirstCapacity : kImageChunkCapacity; + final take = math.min(capacity, payload.length - offset); + final body = BytesBuilder(); + if (i == 0) body.addByte(metadata.encode()); + if (take > 0) body.add(payload.sublist(offset, offset + take)); + offset += take; + bodies.add(body.toBytes()); + } + + final blobs = []; + for (var i = 0; i < total; i++) { + final blob = BytesBuilder() + ..add(_writeHeader(senderPrefix, imgId, i, total)) + ..add(bodies[i]); + blobs.add(blob.toBytes()); + } + + if (parity) { + final xor = Uint8List(kImageChunkBodyBytes); + var lenXor = 0; + for (final body in bodies) { + lenXor ^= body.length; + for (var j = 0; j < body.length; j++) { + xor[j] ^= body[j]; + } + } + final blob = BytesBuilder() + ..add(_writeHeader(senderPrefix, imgId, total, total)) + ..addByte(lenXor & 0xFF) + ..add(xor); + blobs.add(blob.toBytes()); + } + + return ImageChunkSet( + blobs: blobs, + dataChunkCount: total, + hasParity: parity, + imgId: imgId, + senderPrefix: senderPrefix, + ); +} + +/// Hands out per-image ids, avoiding immediate reuse. +/// +/// The id is only 8 bits, so it wraps; the reassembly key also includes the +/// sender prefix and channel, and entries expire after +/// [kImageReassemblyTtl], which bounds the damage of a wrap. +class ImageIdAllocator { + int _next; + + ImageIdAllocator({int? seed, math.Random? random}) + : _next = seed ?? (random ?? math.Random()).nextInt(256); + + int next() { + final id = _next & 0xFF; + _next = (_next + 1) & 0xFF; + return id; + } +} + +// --------------------------------------------------------------------------- +// Reassembly (receive side) +// --------------------------------------------------------------------------- + +/// Identity of one in-flight image. +class ImageStreamKey { + final int senderPrefix; + final int imgId; + final int channelIndex; + + const ImageStreamKey({ + required this.senderPrefix, + required this.imgId, + required this.channelIndex, + }); + + @override + bool operator ==(Object other) => + other is ImageStreamKey && + other.senderPrefix == senderPrefix && + other.imgId == imgId && + other.channelIndex == channelIndex; + + @override + int get hashCode => Object.hash(senderPrefix, imgId, channelIndex); + + @override + String toString() => 'ImageStreamKey(0x' + '${senderPrefix.toRadixString(16).padLeft(4, '0')}/$imgId@$channelIndex)'; +} + +/// What happened to a single received blob. +enum ImageChunkStatus { + /// Not a well-formed chunk of ours; ignored. + malformed, + + /// Chunk claims our own sender prefix — a loopback of something we sent. + fromSelf, + + /// Stored; the image is still incomplete. + accepted, + + /// Already had this chunk; ignored. + duplicate, + + /// Conflicted with what we already held for this key (different `total`, or + /// a different body for the same index). The stream was reset and restarted + /// from this chunk. + conflicting, + + /// Reassembled, but chunk 0's metadata byte names a rate point or resolution + /// this build cannot decode. Also discarded — see + /// [ImageStreamMetadata.decode]. + unsupportedFormat, + + /// This chunk completed the image; [ImageChunkOutcome.result] is set. + completed, +} + +/// Why an image was given up on. +enum ImageReassemblyFailureReason { + /// TTL elapsed with chunks still missing. + expired, + + /// Evicted to keep the pending map inside its size cap. + overflow, + + + /// Reassembled but the metadata byte was undecodable + /// ([ImageChunkStatus.unsupportedFormat]). + unsupportedFormat, +} + +/// A fully reassembled image. +class ImageReassemblyResult { + final ImageStreamKey key; + + /// Null when chunk 0 was recovered but carried an unknown metadata byte. + final ImageStreamMetadata? metadata; + + /// The encoded image bitstream, exactly as the sender produced it. + final Uint8List data; + + /// True when one chunk was rebuilt from the XOR parity chunk. + final bool recoveredWithParity; + + /// Number of data chunks in the image. + final int chunkCount; + + + const ImageReassemblyResult({ + required this.key, + required this.metadata, + required this.data, + required this.recoveredWithParity, + required this.chunkCount, + }); +} + +/// A stream that was given up on: expired, evicted, corrupt or undecodable. +class ImageReassemblyFailure { + final ImageStreamKey key; + final int total; + final int receivedDataChunks; + final bool hadParity; + final DateTime firstSeen; + final DateTime expiredAt; + + /// Why it was given up on. Defaults to [ImageReassemblyFailureReason.expired] + /// so existing call sites keep compiling. + final ImageReassemblyFailureReason reason; + + const ImageReassemblyFailure({ + required this.key, + required this.total, + required this.receivedDataChunks, + required this.hadParity, + required this.firstSeen, + required this.expiredAt, + this.reason = ImageReassemblyFailureReason.expired, + }); + + int get missingChunks => total - receivedDataChunks; + + /// True when every chunk arrived but the bytes were unusable — the UI should + /// say "corrupt", not "incomplete". + bool get isCorrupt => + reason == ImageReassemblyFailureReason.unsupportedFormat; + + @override + String toString() => 'ImageReassemblyFailure($key, ' + '$receivedDataChunks/$total, parity: $hadParity, ${reason.name})'; +} + +/// Result of feeding one blob to [ImageReassembler.addChunk]. +class ImageChunkOutcome { + final ImageChunkStatus status; + final ImageChunkHeader? header; + final ImageReassemblyResult? result; + + const ImageChunkOutcome(this.status, {this.header, this.result}); + + bool get isComplete => status == ImageChunkStatus.completed; +} + +/// A recently-delivered image, kept for [kImageReassemblyTtl] so late chunks can +/// be distinguished from a new image that reuses the same img_id. +class _CompletedImage { + final DateTime at; + final int total; + final Map bodies; + final Uint8List? parityBody; + + _CompletedImage({ + required this.at, + required this.total, + required this.bodies, + required this.parityBody, + }); + + /// True when [header]/[body] is a re-send of something already delivered. + /// + /// Conservative by design: anything that does not byte-match what we + /// delivered is treated as new, because dropping a real image is far worse + /// than re-opening a stream for a straggler. + bool matches(ImageChunkHeader header, List body) { + if (header.total != total) return false; + // In the loss-free case the image completes on its last DATA chunk, so the + // parity chunk arrives afterwards and we never stored one. Parity is a pure + // function of the delivered bodies, so recompute it rather than guessing: + // that keeps the trailing parity a duplicate while still letting a genuinely + // different image through. + final known = header.isParity + ? (parityBody ?? _expectedParityBody()) + : bodies[header.index]; + if (known == null) return false; + if (known.length != body.length) return false; + for (var i = 0; i < known.length; i++) { + if (known[i] != body[i]) return false; + } + return true; + } + + /// The parity body this image would have produced, mirroring + /// [buildImageChunks]: `[len_xor] + XOR(bodies zero-padded)`. + Uint8List? _expectedParityBody() { + if (bodies.length != total) return null; + final xor = Uint8List(kImageChunkBodyBytes); + var lenXor = 0; + for (var i = 0; i < total; i++) { + final b = bodies[i]; + if (b == null) return null; + lenXor ^= b.length; + for (var j = 0; j < b.length; j++) { + xor[j] ^= b[j]; + } + } + return Uint8List.fromList([lenXor & 0xFF, ...xor]); + } +} + +class _PendingImage { + final ImageStreamKey key; + final int total; + final DateTime firstSeen; + final Map bodies = {}; + Uint8List? parityBody; + DateTime lastSeen; + + _PendingImage({ + required this.key, + required this.total, + required this.firstSeen, + }) : lastSeen = firstSeen; + + bool get hasParity => parityBody != null; + + bool get isComplete => bodies.length == total; + + bool get isRecoverable => bodies.length == total - 1 && hasParity; +} + +/// Collects chunks into whole images. Pure Dart, no IO, injectable clock. +/// +/// Out-of-order and duplicate tolerant. Entries older than [ttl] (measured +/// from the first chunk seen for that image) are evicted and reported through +/// [onFailed]. +class ImageReassembler { + /// Prefix of the local node's own public key; chunks bearing it are dropped + /// as loopback. Null disables the check. + final int? selfPrefix; + + final Duration ttl; + + /// Hard cap on concurrently tracked images; the oldest is evicted (and + /// reported as failed) when exceeded. + final int maxConcurrentStreams; + + /// Hard cap on remembered COMPLETED images; the oldest is dropped when + /// exceeded. + /// + /// Without this the map was bounded only by [ttl] times the packet rate. A + /// lone parity chunk with `total == 1, idx == 1` completes a whole image by + /// itself, so every single received packet — noise, a fuzzer, a hostile + /// neighbour — could mint one entry, each retaining up to + /// [kImageMaxDataChunks] * [kImageChunkBodyBytes] of bodies. [_pending] was + /// already capped; this is the same cap on the other map. + final int maxCompletedStreams; + + final void Function(ImageReassemblyResult result)? onImage; + final void Function(ImageReassemblyFailure failure)? onFailed; + + final DateTime Function() _clock; + + final Map _pending = + {}; + + /// Keys completed within the last [ttl]. Needed because in the loss-free + /// case the parity chunk arrives AFTER the image is already complete; without + /// this it would open a fresh stream that could never finish and would later + /// be reported as a failure. + /// + /// The delivered bodies are retained (a completed image is at most + /// [kImageMaxDataChunks] * [kImageChunkBodyBytes], a couple of KiB) so a late + /// chunk can be told apart from a genuinely NEW image that happens to reuse + /// the same img_id. Keying on time alone silently swallowed the latter: + /// [ImageIdAllocator] seeds from `Random().nextInt(256)`, so a restart can + /// re-roll onto an id used seconds earlier. + final Map _recentlyCompleted = + {}; + + ImageReassembler({ + this.selfPrefix, + this.ttl = kImageReassemblyTtl, + this.maxConcurrentStreams = 8, + this.maxCompletedStreams = 8, + this.onImage, + this.onFailed, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + /// Number of images currently being reassembled. + int get pendingCount => _pending.length; + + /// Keys of the images currently being reassembled (test/debug aid). + Iterable get pendingKeys => _pending.keys; + + /// Number of recently-completed images remembered for straggler detection. + int get completedCount => _recentlyCompleted.length; + + /// Keys of the remembered completed images (test/debug aid). + Iterable get completedKeys => _recentlyCompleted.keys; + + /// Drops everything (e.g. on disconnect). Does not fire [onFailed]. + void clear() { + _pending.clear(); + _recentlyCompleted.clear(); + } + + /// Feeds one received GRP_DATA blob. + /// + /// [now] overrides the clock for tests. Expired entries are swept first, so + /// a caller that only ever calls [addChunk] still gets TTL behaviour. + ImageChunkOutcome addChunk( + Uint8List blob, { + int channelIndex = 0, + DateTime? now, + }) { + final at = now ?? _clock(); + evictExpired(now: at); + + final header = parseImageChunkHeader(blob); + if (header == null) { + return const ImageChunkOutcome(ImageChunkStatus.malformed); + } + if (selfPrefix != null && header.senderPrefix == selfPrefix) { + return ImageChunkOutcome(ImageChunkStatus.fromSelf, header: header); + } + + final body = Uint8List.sublistView(blob, kImageChunkHeaderBytes); + if (header.isParity && body.isEmpty) { + // A parity chunk must carry at least its length byte. + return ImageChunkOutcome(ImageChunkStatus.malformed, header: header); + } + + final key = ImageStreamKey( + senderPrefix: header.senderPrefix, + imgId: header.imgId, + channelIndex: channelIndex, + ); + + final completed = _recentlyCompleted[key]; + if (completed != null) { + if (completed.matches(header, body)) { + // Trailing chunk (usually parity) for an image we already delivered. + return ImageChunkOutcome(ImageChunkStatus.duplicate, header: header); + } + // Same key but different content: this is a NEW image reusing the id, not + // a straggler. Forget the completed one and fall through so the normal + // pending/conflict path can start a fresh stream. Dropping this as a + // duplicate would lose the image with no diagnostic at all. + _recentlyCompleted.remove(key); + } + + var conflicted = false; + var entry = _pending[key]; + if (entry != null && entry.total != header.total) { + // Same key, different shape: an id wrap or a new image reusing the id. + _pending.remove(key); + entry = null; + conflicted = true; + } + if (entry == null) { + entry = _PendingImage(key: key, total: header.total, firstSeen: at); + _pending[key] = entry; + _evictOverflow(at); + } + entry.lastSeen = at; + + if (header.isParity) { + if (entry.parityBody != null) { + return ImageChunkOutcome(ImageChunkStatus.duplicate, header: header); + } + entry.parityBody = Uint8List.fromList(body); + } else { + final existing = entry.bodies[header.index]; + if (existing != null) { + if (_sameBytes(existing, body)) { + return ImageChunkOutcome(ImageChunkStatus.duplicate, header: header); + } + // Same index, different content: treat as a new image on a reused id. + _pending.remove(key); + final fresh = _PendingImage( + key: key, + total: header.total, + firstSeen: at, + ); + fresh.bodies[header.index] = Uint8List.fromList(body); + _pending[key] = fresh; + return ImageChunkOutcome(ImageChunkStatus.conflicting, header: header); + } + entry.bodies[header.index] = Uint8List.fromList(body); + } + + final finish = _tryFinish(entry); + if (finish != null) { + _pending.remove(key); + // Remembered even when the bytes were bad: a verbatim re-send of the same + // damaged chunks must not re-open the stream, while a genuine + // retransmission (different bytes) still fails `matches` and starts a + // fresh one. + _remember(key, at, entry); + final result = finish.result; + if (result != null) { + onImage?.call(result); + } else { + onFailed?.call( + ImageReassemblyFailure( + key: key, + total: entry.total, + receivedDataChunks: entry.bodies.length, + hadParity: entry.hasParity, + firstSeen: entry.firstSeen, + expiredAt: at, + reason: finish.reason!, + ), + ); + } + return ImageChunkOutcome( + finish.status, + header: header, + result: result, + ); + } + return ImageChunkOutcome( + conflicted ? ImageChunkStatus.conflicting : ImageChunkStatus.accepted, + header: header, + ); + } + + /// Removes streams whose first chunk is older than [ttl], reporting each + /// through [onFailed]. Returns the failures, oldest first. + List evictExpired({DateTime? now}) { + final at = now ?? _clock(); + _recentlyCompleted.removeWhere((_, c) => at.difference(c.at) >= ttl); + final expired = []; + _pending.removeWhere((key, entry) { + if (at.difference(entry.firstSeen) < ttl) return false; + expired.add( + ImageReassemblyFailure( + key: key, + total: entry.total, + receivedDataChunks: entry.bodies.length, + hadParity: entry.hasParity, + firstSeen: entry.firstSeen, + expiredAt: at, + ), + ); + return true; + }); + expired.sort((a, b) => a.firstSeen.compareTo(b.firstSeen)); + for (final failure in expired) { + onFailed?.call(failure); + } + return expired; + } + + /// Records a finished (delivered OR rejected) image and keeps + /// [_recentlyCompleted] inside [maxCompletedStreams], oldest first. + void _remember(ImageStreamKey key, DateTime at, _PendingImage entry) { + _recentlyCompleted[key] = _CompletedImage( + at: at, + total: entry.total, + bodies: Map.from(entry.bodies), + parityBody: entry.parityBody, + ); + while (_recentlyCompleted.length > maxCompletedStreams) { + ImageStreamKey? oldestKey; + DateTime? oldest; + _recentlyCompleted.forEach((k, c) { + if (oldest == null || c.at.isBefore(oldest!)) { + oldest = c.at; + oldestKey = k; + } + }); + if (oldestKey == null) return; + _recentlyCompleted.remove(oldestKey); + } + } + + void _evictOverflow(DateTime at) { + while (_pending.length > maxConcurrentStreams) { + ImageStreamKey? oldestKey; + DateTime? oldest; + _pending.forEach((key, entry) { + if (oldest == null || entry.firstSeen.isBefore(oldest!)) { + oldest = entry.firstSeen; + oldestKey = key; + } + }); + if (oldestKey == null) return; + final victim = _pending.remove(oldestKey)!; + onFailed?.call( + ImageReassemblyFailure( + key: victim.key, + total: victim.total, + receivedDataChunks: victim.bodies.length, + hadParity: victim.hasParity, + firstSeen: victim.firstSeen, + expiredAt: at, + reason: ImageReassemblyFailureReason.overflow, + ), + ); + } + } + + _FinishOutcome? _tryFinish(_PendingImage entry) { + if (entry.isComplete) { + return _assemble(entry, recovered: false); + } + if (!entry.isRecoverable) return null; + + // Exactly one data chunk missing and we hold parity: rebuild it. + final missing = + List.generate(entry.total, (i) => i).firstWhere((i) => !entry.bodies.containsKey(i)); + final parity = entry.parityBody!; + var lengthXor = parity[0] & 0xFF; + final xor = Uint8List(kImageChunkBodyBytes); + final parityData = parity.length - kImageParityLengthBytes; + for (var i = 0; i < parityData && i < xor.length; i++) { + xor[i] = parity[kImageParityLengthBytes + i]; + } + for (final body in entry.bodies.values) { + lengthXor ^= body.length; + for (var i = 0; i < body.length; i++) { + xor[i] ^= body[i]; + } + } + if (lengthXor > kImageChunkBodyBytes) return null; // corrupt parity + if (missing == 0 && lengthXor < kImageChunkZeroMetadataBytes) return null; + // Only the LAST data chunk may be short; every earlier one is full by + // construction. Without this, a single flipped bit in the parity length + // byte silently yields a truncated image reported as `completed` (a + // 3-chunk image recovering as 398 bytes instead of 400), which is worse + // than failing: the caller has no way to know the bytes are wrong. + if (missing < entry.total - 1 && lengthXor != kImageChunkBodyBytes) { + return null; + } + entry.bodies[missing] = Uint8List.sublistView(xor, 0, lengthXor); + return _assemble(entry, recovered: true); + } + + _FinishOutcome? _assemble(_PendingImage entry, {required bool recovered}) { + final first = entry.bodies[0]; + // Chunk 0 must hold the metadata byte; anything shorter cannot + // be a chunk 0 from this framing, so keep waiting rather than guessing. + if (first == null || first.length < kImageChunkZeroMetadataBytes) { + return null; + } + final metadata = ImageStreamMetadata.decode(first[0]); + final out = BytesBuilder(); + out.add(Uint8List.sublistView(first, kImageChunkZeroMetadataBytes)); + for (var i = 1; i < entry.total; i++) { + final body = entry.bodies[i]; + if (body == null) return null; + out.add(body); + } + final data = out.toBytes(); + if (metadata == null) { + return const _FinishOutcome.failed( + ImageChunkStatus.unsupportedFormat, + ImageReassemblyFailureReason.unsupportedFormat, + ); + } + return _FinishOutcome.delivered( + ImageReassemblyResult( + key: entry.key, + metadata: metadata, + data: data, + recoveredWithParity: recovered, + chunkCount: entry.total, + ), + ); + } +} + +/// A terminal verdict on a fully-arrived image: delivered, or rejected with a +/// reason. `null` (never an instance of this) means "still waiting". +class _FinishOutcome { + final ImageChunkStatus status; + final ImageReassemblyResult? result; + final ImageReassemblyFailureReason? reason; + + const _FinishOutcome.failed(this.status, this.reason) : result = null; + + const _FinishOutcome.delivered(ImageReassemblyResult this.result) + : status = ImageChunkStatus.completed, + reason = null; +} + +bool _sameBytes(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Protocol glue — still pure Dart, but MeshCore-frame shaped +// --------------------------------------------------------------------------- + +/// Builds a `CMD_SEND_CHANNEL_DATA` (62) frame. +/// +/// Flood case (the default) emits exactly +/// `[0x3E][channel_idx][0xFF][type_lo][type_hi][...blob...]`. +/// When [pathLen] != [outPathUnknown] the packed path bytes are inserted +/// before the data type, per MyMesh.cpp:1147-1186. +/// +/// The firmware replies with `RESP_CODE_OK` (0x00) — NOT `RESP_CODE_SENT` — +/// or `[0x01][err]`. +Uint8List buildSendChannelDataFrame({ + required int channelIndex, + required int dataType, + required Uint8List payload, + int pathLen = outPathUnknown, + Uint8List? path, +}) { + final out = BytesBuilder() + ..addByte(cmdSendChannelData) + ..addByte(channelIndex & 0xFF) + ..addByte(pathLen & 0xFF); + if (pathLen != outPathUnknown && path != null) out.add(path); + out + ..addByte(dataType & 0xFF) + ..addByte((dataType >> 8) & 0xFF) + ..add(payload); + return out.toBytes(); +} + +/// A parsed `RESP_CODE_CHANNEL_DATA_RECV` (27) frame. +/// +/// Fixed 9-byte header, no path bytes and no sender identity — which is why +/// the sender prefix lives inside the chunk itself. +class ParsedChannelData { + /// Signed; divide by 4.0 for dB. + final int snrRaw; + final int channelIndex; + + /// 0xFF means the packet arrived via a known/direct path; anything else is + /// the packed flood path_len byte. + final int pathLenByte; + + final int dataType; + final Uint8List payload; + + const ParsedChannelData({ + required this.snrRaw, + required this.channelIndex, + required this.pathLenByte, + required this.dataType, + required this.payload, + }); + + bool get arrivedByFlood => pathLenByte != 0xFF; + + double get snrDb => snrRaw / 4.0; + + int? get hopCount => arrivedByFlood ? pathLenByte & 0x3F : null; + + int? get pathHashWidth => + arrivedByFlood ? ((pathLenByte >> 6) & 0x03) + 1 : null; +} + +/// Parses a `RESP_CODE_CHANNEL_DATA_RECV` frame, or null if it is not one. +ParsedChannelData? parseChannelDataFrame(Uint8List frame) { + if (frame.length < 9) return null; + if (frame[0] != respCodeChannelDataRecv) return null; + final dataLen = frame[8]; + if (frame.length < 9 + dataLen) return null; + final snr = frame[1] >= 128 ? frame[1] - 256 : frame[1]; + return ParsedChannelData( + snrRaw: snr, + channelIndex: frame[4], + pathLenByte: frame[5], + dataType: frame[6] | (frame[7] << 8), + payload: Uint8List.fromList(frame.sublist(9, 9 + dataLen)), + ); +} + +/// Sends one blob on a channel and completes when the device acknowledges it. +/// +/// Implemented by the connector-facing adapter; kept as a typedef so the +/// transport itself never imports the connector (and stays testable). +typedef ChannelBlobSender = Future Function( + Uint8List blob, + int channelIndex, +); + +/// Progress report while an image is going out. +class ImageSendProgress { + final int sentChunks; + final int totalChunks; + final bool isParityChunk; + + const ImageSendProgress({ + required this.sentChunks, + required this.totalChunks, + required this.isParityChunk, + }); + + double get fraction => totalChunks == 0 ? 1 : sentChunks / totalChunks; +} + +/// Thin IO glue: chunk an image, send its blobs strictly one at a time, and +/// route inbound frames into an [ImageReassembler]. +/// +/// Serialisation is not optional. `_pendingGenericAckQueue` in the connector is +/// a strict FIFO keyed only on arrival order — there is no request id in the +/// companion protocol — so two concurrent `CMD_SEND_CHANNEL_DATA` frames would +/// cross their acknowledgements. +class ImageChunkTransport { + final ChannelBlobSender send; + final ImageReassembler reassembler; + final ImageIdAllocator _ids; + + /// Prefix of the local public key, stamped into every outgoing chunk. + int senderPrefix; + + ImageChunkTransport({ + required this.send, + required this.reassembler, + required this.senderPrefix, + ImageIdAllocator? idAllocator, + }) : _ids = idAllocator ?? ImageIdAllocator(); + + Future _sendQueue = Future.value(); + + /// Chunks and transmits [payload]. Chunks go out strictly sequentially, and + /// concurrent calls are serialised behind each other. + /// + /// Returns the chunk set that was sent. + Future sendImage({ + required Uint8List payload, + required ImageStreamMetadata metadata, + int channelIndex = 0, + bool parity = true, + int? imgId, + void Function(ImageSendProgress progress)? onProgress, + }) { + final set = buildImageChunks( + payload: payload, + metadata: metadata, + senderPrefix: senderPrefix, + imgId: imgId ?? _ids.next(), + parity: parity, + ); + final completed = _sendQueue.then((_) async { + for (var i = 0; i < set.blobs.length; i++) { + await send(set.blobs[i], channelIndex); + onProgress?.call( + ImageSendProgress( + sentChunks: i + 1, + totalChunks: set.blobs.length, + isParityChunk: set.hasParity && i == set.blobs.length - 1, + ), + ); + } + return set; + }); + // Keep the queue alive even if this send fails. + _sendQueue = completed.then((_) {}, onError: (Object _) {}); + return completed; + } + + /// Feeds a raw inbound companion frame. Non-image frames are ignored. + /// + /// NOTE: the queued-message sync advance (`_handleQueuedMessageReceived`) + /// still has to happen inside `_handleFrame`; a `receivedFrames` listener + /// alone will not prevent the 5 s CMD_SYNC_NEXT_MESSAGE stall. + ImageChunkOutcome? handleFrame(Uint8List frame) { + final parsed = parseChannelDataFrame(frame); + if (parsed == null) return null; + if (parsed.dataType != dataTypeAeicImage) return null; + return reassembler.addChunk( + parsed.payload, + channelIndex: parsed.channelIndex, + ); + } +} diff --git a/lib/services/image_codec_backend.dart b/lib/services/image_codec_backend.dart new file mode 100644 index 00000000..6b2b74b9 --- /dev/null +++ b/lib/services/image_codec_backend.dart @@ -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 load(Object bundle); + + /// Runs the synthesis half: latent -> packed 8-bit RGB. + Future decodeLatentToRgb({ + required Float32List yHat, + void Function(double progress)? onProgress, + bool Function()? shouldCancel, + }); + + Future encode({ + required Uint8List rgbBytes, + required AeicRatePoint ratePoint, + required int resolution, + void Function(double progress)? onProgress, + bool Function()? shouldCancel, + }); + + Future 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 releaseDecoderSession(); + + /// Drops the 67 MB entropy session, keeping the synthesis half. + Future releaseEntropySession(); + + Future 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 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 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 _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 _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 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 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 = []; + 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({_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 flat, List 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 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 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 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 releaseEntropySession() async { + final encodeSession = _entropyEncode; + final decodeSession = _entropyDecode; + _entropyEncode = null; + _entropyDecode = null; + await _close(encodeSession, 'entropy(encode)'); + await _close(decodeSession, 'entropy(decode)'); + } + + @override + Future dispose() async { + await releaseDecoderSession(); + await releaseEntropySession(); + _coders = null; + } + + static Future _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 kZqShape = [1, 128, 4, 4]; + static const List kBaseShape = [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 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 = { + kImageInput: await OrtValue.fromList(imageChw, [ + 1, + 3, + resolution, + resolution, + ]), + }; + final result = await _run(session, inputs); + try { + return AeicEncodeSideTensors( + zQ: await _floats(result, 'z_q'), + yQ: [ + for (var i = 0; i < 4; i++) await _floats(result, 'yq$i'), + ], + scales: [ + for (var i = 0; i < 4; i++) await _floats(result, 'sc$i'), + ], + ); + } finally { + await _disposeAll(result); + } + } + + @override + Future 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, { + kZqInput: await OrtValue.fromList(zQ, kZqShape), + kBaseInput: await OrtValue.fromList( + Float32List(kBaseElements), + kBaseShape, + ), + kStageInput: await OrtValue.fromList(Int32List.fromList([ + kHyperStage, + ]), [1]), + }); + try { + return await _floats(result, kBase0Output); + } finally { + await _disposeAll(result); + } + } + + @override + Future 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, { + kZqInput: await OrtValue.fromList(Float32List(kZqElements), kZqShape), + kBaseInput: await OrtValue.fromList(base, kBaseShape), + kStageInput: await OrtValue.fromList(Int32List.fromList([ + stage, + ]), [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> _run( + OrtSession session, + Map inputs, + ) async { + try { + return await session.run(inputs); + } finally { + for (final value in inputs.values) { + await value.dispose(); + } + } + } + + static Future _floats( + Map 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 _disposeAll(Map 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(); +} diff --git a/lib/services/image_codec_entropy.dart b/lib/services/image_codec_entropy.dart new file mode 100644 index 00000000..31da1050 --- /dev/null +++ b/lib/services/image_codec_entropy.dart @@ -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 get yShape => [1, yChannels, yHeight, yWidth]; + + List get zShape => [1, zChannels, zHeight, zWidth]; + + List get imageShape => [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 stagePermutation = [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`; 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 yQ; + + /// `scales_supp_i * mask_i` per stage, `[1, M, y_h, y_w]`. + final List 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 runEncodeSide(Float32List imageChw); + + /// `h_s(z_q + z_offset)[:, :, :y_h, :y_w]`. + Future runHyperSynthesis(Float32List zQ); + + /// `adapter_out[stage](g_c(adapter_in[stage](base)))`, unmasked. + Future 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 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 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.'; +} diff --git a/lib/services/image_codec_file_store.dart b/lib/services/image_codec_file_store.dart new file mode 100644 index 00000000..da5a56f1 --- /dev/null +++ b/lib/services/image_codec_file_store.dart @@ -0,0 +1,2 @@ +export 'image_codec_file_store_stub.dart' + if (dart.library.io) 'image_codec_file_store_io.dart'; diff --git a/lib/services/image_codec_file_store_io.dart b/lib/services/image_codec_file_store_io.dart new file mode 100644 index 00000000..70eafba1 --- /dev/null +++ b/lib/services/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 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> scanDownloadedModels() async { + final dir = Directory(await modelDirectoryPath()); + if (!dir.existsSync()) { + return const []; + } + final models = []; + for (final entity in dir.listSync().whereType()) { + final name = entity.uri.pathSegments.last; + if (name.startsWith('.')) { + // Hidden `._chunk_` 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 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 deletePartialDownloads(String fileName) async { + if (fileName.isEmpty) return; + final dir = Directory(await modelDirectoryPath()); + if (!dir.existsSync()) return; + // `.[.]_chunk_`. 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()) { + if (pattern.hasMatch(entity.uri.pathSegments.last)) { + await entity.delete(); + } + } + } + + Future deleteFile(String path) async { + final file = File(path); + if (file.existsSync()) { + await file.delete(); + } + } + + Future writeModelBytes({ + required String fileName, + required Stream> 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 chunkFilePath(String fileName, int index) async { + final dir = await modelDirectoryPath(); + return '$dir/.${fileName}_chunk_$index'; + } + + Future 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 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 appendBytes({ + required String path, + required Stream> 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 sha256OfFile(String path) async { + final accumulator = AccumulatorSink(); + 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 readFileBytes(String path) => File(path).readAsBytes(); + + Future combineChunks({ + required String fileName, + required List 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, + }); +} diff --git a/lib/services/image_codec_file_store_stub.dart b/lib/services/image_codec_file_store_stub.dart new file mode 100644 index 00000000..f4ad5de4 --- /dev/null +++ b/lib/services/image_codec_file_store_stub.dart @@ -0,0 +1,68 @@ +import 'dart:typed_data'; + +import '../models/image_codec_support.dart'; + +class ImageCodecFileStore { + Future modelDirectoryPath() async { + throw UnsupportedError('Local codec model storage is not supported on web.'); + } + + Future> scanDownloadedModels() async { + return const []; + } + + Future deleteModel(ImageCodecModelRecord model) async {} + + Future deleteFile(String path) async {} + + Future writeModelBytes({ + required String fileName, + required Stream> chunks, + }) async { + throw UnsupportedError('Local codec model storage is not supported on web.'); + } + + Future chunkFilePath(String fileName, int index) async { + throw UnsupportedError('Local codec model storage is not supported on web.'); + } + + Future modelFilePath(String fileName) async { + throw UnsupportedError('Local codec model storage is not supported on web.'); + } + + Future deletePartialDownloads(String fileName) async {} + + Future fileSize(String path) async => 0; + + Future appendBytes({ + required String path, + required Stream> chunks, + }) async { + throw UnsupportedError('Local codec model storage is not supported on web.'); + } + + Future sha256OfFile(String path) async { + throw UnsupportedError('Local codec model storage is not supported on web.'); + } + + Future readFileBytes(String path) async { + throw UnsupportedError('Local file reads are not supported on web.'); + } + + Future combineChunks({ + required String fileName, + required List 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, + }); +} diff --git a/lib/services/image_codec_service.dart b/lib/services/image_codec_service.dart new file mode 100644 index 00000000..fca19b7a --- /dev/null +++ b/lib/services/image_codec_service.dart @@ -0,0 +1,1571 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../models/image_codec_support.dart'; +import '../utils/app_logger.dart'; +import '../widgets/image_send_codec_binding.dart'; +import 'app_settings_service.dart'; +import 'image_codec_backend.dart' show kImageCodecBitstreamPathAvailable; +import 'image_codec_file_store.dart'; +import 'image_codec_session.dart'; +import 'image_codec_settings_store.dart'; + +/// Neural image codec (AEIC-SE) for sending photographs over the mesh. +/// +/// Structurally a sibling of [TranslationService] — same model registry, same +/// `_runExclusive` serial lock, same load/unload lifecycle — with four +/// deliberate differences. Read `translation_service.dart` alongside this file: +/// +/// 1. The heavy work runs in a worker isolate this class owns +/// ([ImageCodecSession]) rather than inside a package that owns its own +/// thread. A 1385 GFLOP decode on the root isolate would stall the BLE +/// notify stream and drop mesh traffic. +/// 2. [handleMemoryPressure] exists. The decode graph is ~886M parameters and +/// 2.16 GiB peak resident; it must be evicted on background and on memory +/// warnings or Android's low-memory killer takes the whole app. Do NOT copy +/// the translation stack's hold-until-disconnect behaviour. +/// 3. The download is **resumable and checksum-verified**, and fetches a *list* +/// of assets, because the model is an ONNX graph plus an 869 MiB +/// external-weights sibling. See the `---- download` section. +/// 4. Preferences live in [ImageCodecSettingsStore] until the `imageCodec*` +/// fields land on `AppSettings` (see that file's doc comment). +/// +/// ## Two sessions, two lifetimes +/// +/// The installed model is a *bundle* ([ImageCodecBundle]): a ~2.16 GiB-peak +/// synthesis decoder, two fp32 entropy graphs (64 MB send-side, 58 MB +/// decode-side, ~0.35 GiB peak and only ever one of them resident) and 813 KB +/// of CDF tables. Encoding needs only the send-side entropy graph; decoding +/// needs the decode-side graph and then the synthesis decoder, one after the +/// other — both resident at once measures 2.44 GiB. The worker therefore +/// creates ORT sessions lazily and can drop either half on its own +/// ([releaseDecoderSession] / [releaseEntropySession]), so a send never pays +/// for the decoder and [handleMemoryPressure] can shed the expensive half while +/// leaving the send path warm. +/// +/// ## What works today +/// +/// [decodeLatent] — the real ONNX synthesis pass, latent to pixels — plus +/// whatever the backend's entropy path supports. While +/// [kImageCodecBitstreamPathAvailable] is false, [encode] and [decodeBitstream] +/// throw [ImageCodecEntropyPathMissing] and [availability] reports +/// `unavailable` with [unavailableReason] carrying the sentence to show the +/// user. A build where that gate is true but the *installed files* are +/// decoder-only is a different, recoverable state: `disabled` + +/// [needsModelUpgrade] + [ImageCodecBundleIncomplete]. +/// +/// It implements [ImageSendCodec] directly, so `ImageSendPreviewSheet` and +/// `pickAndPreviewImage` bind to it with no adapter — replace +/// `FakeImageSendCodec()` at the two call sites in `chat_screen.dart` / +/// `channel_chat_screen.dart` with `context.read()`. +class ImageCodecService extends ChangeNotifier implements ImageSendCodec { + final AppSettingsService _appSettingsService; + final ImageCodecFileStore _fileStore; + final ImageCodecSettingsStore _settingsStore; + + /// Creates a new [http.Client] per download attempt. + /// + /// Injectable so the download/resume/checksum logic can be tested against a + /// `MockClient` — without this the only way to exercise an 872 MB two-file + /// resumable transfer would be to actually perform one. + final http.Client Function() _newClient; + + ImageCodecService( + this._appSettingsService, { + ImageCodecFileStore? fileStore, + ImageCodecSettingsStore? settingsStore, + http.Client Function()? clientFactory, + }) : _fileStore = fileStore ?? ImageCodecFileStore(), + _settingsStore = settingsStore ?? PrefsImageCodecSettingsStore(), + _newClient = clientFactory ?? http.Client.new; + + // ---- state (naming/order mirrors TranslationService:50-62) ---------------- + bool _disposed = false; + bool _isBusy = false; + bool _isDownloading = false; + bool _cancelDownloadRequested = false; + String? _lastError; + Future _queue = Future.value(); + ImageCodecSession? _session; + ImageCodecBundle? _loadedBundle; + ImageCodecBundle? _failedBundle; + int _downloadedBytes = 0; + int? _downloadTotalBytes; + String? _downloadFileName; + // codec-specific + double? _codecProgress; + String? _codecStage; + + // ---- getters (mirror translation_service.dart:64-76) --------------------- + bool get isBusy => _isBusy; + bool get isDownloading => _isDownloading; + String? get lastError => _lastError; + int get downloadedBytes => _downloadedBytes; + int? get downloadTotalBytes => _downloadTotalBytes; + String? get downloadFileName => _downloadFileName; + double? get downloadProgress { + final total = _downloadTotalBytes; + if (!_isDownloading || total == null || total <= 0) { + return null; + } + return (_downloadedBytes / total).clamp(0.0, 1.0); + } + + /// 0..1 while an encode or decode is running, null when idle. + double? get codecProgress => _codecProgress; + + /// Short human label for the current codec phase, null when idle. + String? get codecStage => _codecStage; + + bool get isModelLoaded => _loadedBundle != null; + + ImageCodecPreferences get preferences => _settingsStore.preferences; + + /// Composer default rate point. + AeicRatePoint get defaultRatePoint => preferences.aeicRatePoint; + + // ---- availability -------------------------------------------------------- + + /// The signal the compose UI binds to. + /// + /// Mapping decisions worth knowing: + /// * `unavailable` means "this build/device can never do this" — web, a build + /// whose inference backend failed to load, or (today, always) a build + /// without the rANS entropy path. Sending a picture needs bytes, and this + /// build can only turn a *latent* into a picture. See + /// [kImageCodecBitstreamPathAvailable] and [ImageCodecEntropyPathMissing]; + /// [unavailableReason] carries the explanation for the UI. + /// * `disabled` covers "switched off in settings", "no model downloaded + /// yet" and "the installed model predates the entropy bundle", because the + /// sheet's remedy is the same in all three: send the user to settings. + /// [needsModelDownload] and [needsModelUpgrade] distinguish them, and + /// [statusReason] renders the difference as a sentence. + @override + ImageCodecAvailability get availability { + if (unavailableReason != null) { + return ImageCodecAvailability.unavailable; + } + if (!_appSettingsService.settings.imageMessagesEnabled || + !preferences.enabled) { + return ImageCodecAvailability.disabled; + } + if (_isDownloading) { + return ImageCodecAvailability.downloading; + } + final model = selectedModel; + if (model == null || model.localPath.isEmpty) { + return ImageCodecAvailability.disabled; + } + // A decoder-only install cannot encode, and a bundle-version-1 install can + // encode but not decode; neither is "ready", so the compose UI must not say + // so. `disabled` (not `unavailable`) because the remedy is a download — + // see [needsModelUpgrade] and [statusReason]. Kept in step with + // [needsModelUpgrade] on purpose: `ready` plus an upgrade prompt would be a + // contradiction the sheet has no way to render. + if (installedBundle?.supportsDecode != true) { + return ImageCodecAvailability.disabled; + } + return ImageCodecAvailability.ready; + } + + /// Why [availability] is `unavailable`, or null when it is not. + /// + /// EXACTLY THREE CAUSES, all of them permanent properties of the build or the + /// platform, none of them fixable by the user: + /// * web, which has no native inference runtime; + /// * a build compiled without the entropy path + /// ([kImageCodecBitstreamPathAvailable]); + /// * a backend that failed to load. + /// + /// An incomplete or legacy install is deliberately NOT one of them: its + /// remedy is a download, so it is `disabled` + [needsModelUpgrade]. Reporting + /// it here would tell every user of the decoder-only build that their phone + /// can never send a picture, which is false. + @override + String? get unavailableReason { + if (kIsWeb) { + return 'The image codec needs a native inference runtime, which the web ' + 'build does not have.'; + } + if (!kImageCodecBitstreamPathAvailable) { + return 'This build ships the image decoder only. Encoding and decoding a ' + 'bitstream also needs the entropy-side graph and the rANS coder, ' + 'which are not included yet.'; + } + if (_backendMissing) { + return _lastError ?? 'The inference backend failed to load.'; + } + return null; + } + + /// One user-facing sentence for whatever non-ready state the codec is in, or + /// null when it is ready. + /// + /// [unavailableReason] can only explain the three permanent causes, so a + /// sheet that renders it alone falls back to a generic "not available" string + /// for the two states the user can actually DO something about — the feature + /// being switched off and the model not being installed. This getter covers + /// every case and is what a "why can't I send?" banner should show; it is a + /// superset, so `statusReason ?? unavailableReason` is never needed. + String? get statusReason { + final permanent = unavailableReason; + if (permanent != null) { + return permanent; + } + if (!_appSettingsService.settings.imageMessagesEnabled) { + return 'Image messages are switched off. Turn them on in Settings to ' + 'send and receive pictures.'; + } + if (!preferences.enabled) { + return 'The image codec is switched off in Settings.'; + } + if (_isDownloading) { + final name = _downloadFileName; + return name == null + ? 'The image codec model is downloading.' + : 'The image codec model is downloading ($name).'; + } + if (needsModelDownload) { + return 'The image codec model is not downloaded yet. It is about ' + '${(kImageCodecBundleTotalBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ' + 'GB and can be fetched from Settings.'; + } + if (needsModelUpgrade) { + return 'The installed image codec model is missing part of the current ' + 'bundle, so it cannot send and receive pictures. Re-download it in ' + 'Settings.'; + } + if (_lastError != null && !canRunInference) { + return _lastError; + } + return null; + } + + /// True when the feature is on but no weights are on disk. + bool get needsModelDownload { + final model = selectedModel; + return preferences.enabled && (model == null || model.localPath.isEmpty); + } + + /// True when a model IS installed but it is missing part of the current + /// bundle: a decoder-only (pre-bundle) install with no entropy graph and no + /// CDF tables, or a bundle-version-1 install with the send-side entropy graph + /// but no decode-side one. + /// + /// The remedy is a re-download, which is why this is not folded into + /// [unavailableReason]. Keyed off both the recorded [kImageCodecBundleVersion] + /// and the actual paths, so a record hand-edited to claim version 1 without + /// the files still reports true. + bool get needsModelUpgrade { + final model = selectedModel; + if (model == null || model.localPath.isEmpty) { + return false; + } + if (model.bundleVersion < kImageCodecBundleVersion) { + return true; + } + return installedBundle?.supportsDecode != true; + } + + /// Paths of the installed model, or null when nothing is installed. + /// + /// The decoder graph is [ImageCodecModelRecord.localPath]; the two entropy + /// graphs and the CDF tables are resolved BY ROLE, from + /// [ImageCodecModelRecord.assetRoles] first and the registry spec second, so + /// nothing here re-derives a filename by convention. A pre-bundle record + /// simply yields an incomplete bundle. + ImageCodecBundle? get installedBundle { + final model = selectedModel; + if (model == null || model.localPath.isEmpty) { + return null; + } + final spec = _specForId(model.id); + final directory = _directoryOf(model.localPath); + + String? pathFor(ImageCodecAssetRole role, bool Function(String) looksRight) { + // 1. What the installer recorded this file AS. Exact, and the only source + // that can tell the two entropy graphs apart. + final recorded = model.fileNameForRole(role); + if (recorded != null) { + return '$directory/$recorded'; + } + // 2. The registry spec, for records written before assetRoles existed. + final declared = spec?.maybeAssetFor(role)?.fileName; + if (declared != null && model.assetFileNames.contains(declared)) { + return '$directory/$declared'; + } + // 3. Last resort for a custom-URL install or an old record: the recorded + // names, never a guess at a name that was never downloaded. + for (final name in model.assetFileNames) { + if (looksRight(name)) { + return '$directory/$name'; + } + } + return null; + } + + final decoderName = model.localPath.split('/').last; + // The bundle now holds THREE `.onnx` files, so "any .onnx that is not the + // decoder" no longer identifies the send-side graph on its own. The + // heuristic is only ever reached for a record with no matching spec entry, + // and it must not be allowed to hand the decode-side export to the encoder: + // that graph has no `image` input and would fail at the first run. + final decodeGraphName = + model.fileNameForRole(ImageCodecAssetRole.entropyDecodeGraph) ?? + spec?.maybeAssetFor(ImageCodecAssetRole.entropyDecodeGraph)?.fileName; + return ImageCodecBundle( + decoderGraphPath: model.localPath, + entropyGraphPath: pathFor( + ImageCodecAssetRole.entropyGraph, + (name) => + name.endsWith('.onnx') && + name != decoderName && + name != decodeGraphName, + ), + // Declared-name only, no heuristic. A record written before bundle + // version 2 has no decode-side graph on disk at all, and inventing a + // filename for it would trade a clean "re-download" prompt for an opaque + // ORT session-creation failure. + entropyDecodeGraphPath: pathFor( + ImageCodecAssetRole.entropyDecodeGraph, + (_) => false, + ), + tablesPath: pathFor( + ImageCodecAssetRole.cdfTables, + (name) => name.endsWith('.bin'), + ), + ratePoint: spec?.ratePoint ?? preferences.aeicRatePoint, + ); + } + + static String _directoryOf(String path) { + final index = path.lastIndexOf('/'); + return index <= 0 ? path : path.substring(0, index); + } + + ImageCodecModelSpec? _specForId(String id) { + for (final spec in imageCodecPresetModels) { + if (spec.id == id) return spec; + } + return null; + } + + /// Set once a session load has failed with [ImageCodecUnimplemented], i.e. + /// this build has no inference runtime. Sticky: retrying cannot help. + bool _backendMissing = false; + + // ---- registry (mirror translation_service.dart:129-174) ------------------ + List get availableModels => + preferences.downloadedModels; + + ImageCodecModelRecord? get selectedModel { + final selectedId = preferences.selectedModelId; + if (selectedId == null) { + return availableModels.isNotEmpty ? availableModels.first : null; + } + for (final model in availableModels) { + if (model.id == selectedId) { + return model; + } + } + return availableModels.isNotEmpty ? availableModels.first : null; + } + + /// Loads persisted preferences and reconciles them with what is on disk. + /// + /// Call once at startup, next to + /// `await translationService.refreshDownloadedModels();` in `main.dart`. + Future refreshDownloadedModels() async { + if (_isDownloading) return; + await _settingsStore.load(); + if (kIsWeb) { + _notify(); + return; + } + final scanned = await _fileStore.scanDownloadedModels(); + if (scanned.isEmpty) { + _notify(); + return; + } + // The file store reports one record per FILE, which is no longer one record + // per model: a bundle is four files, and taking them at face value would + // list `aeic_cdf_ft32.bin` as an installed codec. Reconcile against the + // registry here, so this survives whatever the store does. + final onDisk = {for (final model in scanned) model.name: model}; + final existingByPath = { + for (final model in preferences.downloadedModels) model.localPath: model, + }; + final merged = []; + // Every filename the registry knows about, so a half-installed bundle's + // leftovers are never mistaken for standalone models. + final claimed = { + for (final spec in imageCodecPresetModels) + for (final asset in spec.assets) asset.fileName, + }; + + for (final spec in imageCodecPresetModels) { + final graphFile = onDisk[spec.fileName]; + if (graphFile == null) continue; + final present = [ + for (final asset in spec.assets) + if (onDisk.containsKey(asset.fileName)) asset.fileName, + ]; + final existing = existingByPath[graphFile.localPath]; + final complete = spec.assets.every( + (asset) => onDisk.containsKey(asset.fileName), + ); + merged.add( + ImageCodecModelRecord( + id: spec.id, + name: spec.fileName, + sourceUrl: existing?.sourceUrl ?? spec.graph.sourceUrl, + localPath: graphFile.localPath, + downloadedAt: existing?.downloadedAt ?? graphFile.downloadedAt, + fileSizeBytes: [ + for (final name in present) onDisk[name]!.fileSizeBytes, + ].fold(0, (sum, size) => sum + size), + assetFileNames: present, + assetRoles: { + for (final asset in spec.assets) + if (onDisk.containsKey(asset.fileName)) asset.fileName: asset.role, + }, + bundleVersion: complete ? kImageCodecBundleVersion : 0, + ), + ); + } + + // Anything not part of a registry bundle is a custom-URL install; keep it + // as its own single-file record so a hand-fetched graph is not deleted. + for (final model in scanned) { + if (claimed.contains(model.name)) continue; + final existing = existingByPath[model.localPath]; + merged.add( + existing?.copyWith(fileSizeBytes: model.fileSizeBytes) ?? model, + ); + } + + var updated = preferences.copyWith(downloadedModels: merged); + _failedBundle = null; + if (updated.selectedModelId == null && merged.isNotEmpty) { + updated = updated.copyWith(selectedModelId: merged.first.id); + } + await _settingsStore.save(updated); + _notify(); + } + + Future setEnabled(bool value) async { + await _settingsStore.save(preferences.copyWith(enabled: value)); + if (!value) { + await releaseModel(); + } + _notify(); + } + + Future setSelectedModelId(String? id) async { + await _settingsStore.save(preferences.copyWith(selectedModelId: id)); + // The cached session belongs to the previous model. + await releaseModel(); + _notify(); + } + + Future setDefaultRatePoint(AeicRatePoint ratePoint) async { + await _settingsStore.save( + preferences.copyWith(ratePoint: ratePoint.wireValue), + ); + _notify(); + } + + // ---- gating -------------------------------------------------------------- + + /// Whether a model file is present and a session could be loaded. + /// + /// This is the ONNX-only capability: it gates [decodeLatentToRgb], which + /// works. [canEncode]/[canDecode] additionally require the entropy path. + bool get canRunInference { + final model = selectedModel; + return !kIsWeb && + !_backendMissing && + model != null && + model.localPath.isNotEmpty; + } + + /// Whether a picture can be turned into bytes on this device right now. + /// + /// Three independent facts, all required: a model is installed and the + /// backend loads ([canRunInference]), the build contains the entropy path + /// ([kImageCodecBitstreamPathAvailable]), and the *installed* bundle carries + /// the entropy graph and the CDF tables. + bool get canEncode => + canRunInference && + kImageCodecBitstreamPathAvailable && + installedBundle?.isComplete == true; + + /// Everything [canEncode] needs, PLUS the decode-side entropy graph. + /// + /// Not an alias for [canEncode] any more. Decoding re-runs the entropy model + /// to reproduce the encoder's symbol probabilities, but it has to do it one + /// stage at a time, which the send-side export cannot do — so a bundle- + /// version-1 install can send a picture and not open one. Treating the two as + /// the same fact would let the receive path start a decode that fails + /// halfway. + bool get canDecode => + canRunInference && + kImageCodecBitstreamPathAvailable && + installedBundle?.supportsDecode == true; + + /// Whether a reassembled chunk set is complete enough to attempt a decode. + /// + /// A truncated AEIC bitstream does not decode to a degraded picture — the + /// rANS decoder desynchronises and produces garbage without raising — so the + /// answer is strictly "all data chunks present". Single-loss recovery is the + /// parity chunk's job in `image_chunk_transport.dart`, upstream of here. + bool canDecodeChunkSet(int received, int total) { + if (!canDecode || total <= 0) return false; + return received >= total; + } + + // ---- download ------------------------------------------------------------- + // + // Derived from translation_service.dart:176-366, then changed in three ways + // that matter at 872 MB across two files: + // + // 1. RESUMABLE. Each of the 8 range chunks is its own file whose *length is + // its progress marker*, so a dropped connection resumes with + // `Range: bytes=-` instead of restarting from zero. The + // chunk filenames embed the total size, so a re-download of a file whose + // length changed upstream can never resume onto stale offsets. + // 2. VERIFIED. A completed file is SHA-256'd against the expected digest + // before it is recorded. Without this, corruption arrived as an opaque + // session-load failure that got poisoned into `_failedModelPath` — a dead + // feature with no diagnostic and no way back except clearing app data. + // 3. MULTI-FILE. A model is a list of assets (graph + external weights), all + // of which must land in one directory under exact names. See + // `ImageCodecModelSpec`. + + static const int _parallelChunks = 8; + static const int _parallelMinBytes = 10 * 1024 * 1024; // 10 MB + + /// Downloads every asset of a declared preset model. + /// + /// Refuses a spec whose URLs are placeholders — see + /// [ImageCodecModelSpec.urlsArePlaceholders] and the banner on + /// [imageCodecPresetModels]. That refusal is the guard against shipping the + /// invented HuggingFace paths. + Future downloadPresetModel( + ImageCodecModelSpec spec, + ) async { + if (spec.urlsArePlaceholders) { + throw StateError( + 'Model "${spec.id}" has placeholder URLs that have never been fetched. ' + 'Publish the weights and update imageCodecPresetModels first.', + ); + } + if (!spec.isComplete) { + throw StateError( + 'Model "${spec.id}" is missing one of the four bundle roles; it would ' + 'install a codec that cannot encode or decode.', + ); + } + return _downloadAssets( + id: spec.id, + assets: spec.assets, + declaredTotalBytes: spec.totalSizeBytes, + // Role-resolved, not positional: the graph handed to ORT is the decoder, + // which is the smallest of the four files and would never be assets.first + // if anyone sorted the list by size. + primary: spec.assetFor(ImageCodecAssetRole.decoderGraph), + bundleVersion: kImageCodecBundleVersion, + ); + } + + /// Downloads a single-file model from an arbitrary URL. + /// + /// Kept for the settings screen's "custom URL" affordance. A model with + /// external weights cannot be expressed this way; use [downloadPresetModel]. + Future downloadModel({ + required String sourceUrl, + String? fileName, + String? id, + }) async { + final uri = Uri.tryParse(sourceUrl); + if (uri == null || !uri.hasScheme) { + throw ArgumentError('Invalid model URL.'); + } + final resolvedFileName = + fileName ?? + _sanitizeFileName( + uri.pathSegments.isNotEmpty + ? uri.pathSegments.last + : 'image-codec-model.onnx', + ); + return _downloadAssets( + id: id ?? resolvedFileName, + assets: [ + ImageCodecModelAsset( + role: ImageCodecAssetRole.decoderGraph, + fileName: resolvedFileName, + sourceUrl: sourceUrl, + sizeBytes: 0, + ), + ], + declaredTotalBytes: 0, + // A single-file install is by definition decoder-only. + bundleVersion: 0, + ); + } + + Future _downloadAssets({ + required String id, + required List assets, + required int declaredTotalBytes, + required int bundleVersion, + ImageCodecModelAsset? primary, + }) async { + for (final asset in assets) { + final uri = Uri.tryParse(asset.sourceUrl); + if (uri == null || !uri.hasScheme) { + throw ArgumentError('Invalid model URL: ${asset.sourceUrl}'); + } + } + return _runExclusive(() async { + _setBusy(true); + _setDownloading(true); + _lastError = null; + try { + _downloadedBytes = 0; + _downloadTotalBytes = declaredTotalBytes > 0 + ? declaredTotalBytes + : null; + _cancelDownloadRequested = false; + + // One progress bar across the whole set: `_downloadedBytes` accumulates + // over every asset and `_downloadTotalBytes` is the sum of all four, so + // a 900 MB bundle shows one monotonic bar rather than four that each + // snap back to zero. `_downloadFileName` names the file in flight. + final graphAsset = primary ?? assets.first; + final downloadedFiles = {}; + for (final asset in assets) { + downloadedFiles[asset.fileName] = await _downloadAsset(asset); + } + + final graph = downloadedFiles[graphAsset.fileName]!; + final record = ImageCodecModelRecord( + id: id, + name: graphAsset.fileName, + sourceUrl: graphAsset.sourceUrl, + localPath: graph.localPath, + downloadedAt: DateTime.now(), + // The whole bundle, not just the graph: this is what the settings + // screen shows next to the installed model, and 3 MB would be a lie + // about 900 MB of storage. + fileSizeBytes: downloadedFiles.values.fold( + 0, + (sum, file) => sum + file.fileSizeBytes, + ), + assetFileNames: [for (final asset in assets) asset.fileName], + // The only place the role of each downloaded file is known for + // certain. Recorded now so nothing downstream has to infer it from a + // filename — see [ImageCodecModelRecord.assetRoles]. + assetRoles: { + for (final asset in assets) asset.fileName: asset.role, + }, + bundleVersion: bundleVersion, + ); + final updated = [ + for (final existing in preferences.downloadedModels) + if (existing.id != record.id) existing, + record, + ]; + await _settingsStore.save( + preferences.copyWith( + downloadedModels: updated, + selectedModelId: record.id, + modelSourceUrl: graphAsset.sourceUrl, + ), + ); + _failedBundle = null; + return record; + } finally { + _setDownloading(false); + } + }); + } + + Future _downloadAsset(ImageCodecModelAsset asset) async { + final uri = Uri.parse(asset.sourceUrl); + _downloadFileName = asset.fileName; + _notify(); + + final finalPath = await _fileStore.modelFilePath(asset.fileName); + + // Already here and intact? Credit its bytes to the progress bar and move on. + // This is what makes retrying after a failure on asset 2 of 2 cheap. + final existing = await _fileStore.fileSize(finalPath); + if (existing > 0 && + (asset.sizeBytes == 0 || existing == asset.sizeBytes) && + await _checksumMatches(asset, finalPath)) { + _downloadedBytes += existing; + _notify(); + return DownloadedCodecFile(localPath: finalPath, fileSizeBytes: existing); + } + if (existing > 0) { + await _fileStore.deleteFile(finalPath); + } + + final head = await _probe(uri); + final totalSize = head.contentLength ?? asset.sizeBytes; + + final DownloadedCodecFile downloaded; + if (head.supportsRange && totalSize > _parallelMinBytes) { + downloaded = await _downloadRanged( + uri: uri, + fileName: asset.fileName, + totalSize: totalSize, + ); + } else { + downloaded = await _downloadSingle(uri: uri, fileName: asset.fileName); + } + + await _verifyChecksum(asset, downloaded.localPath); + await _fileStore.deletePartialDownloads(asset.fileName); + return downloaded; + } + + Future<_HeadResult> _probe(Uri uri) async { + final client = _newClient(); + try { + final response = await client.send(http.Request('HEAD', uri)); + await response.stream.drain(); + return _HeadResult( + contentLength: response.contentLength, + supportsRange: + response.headers['accept-ranges']?.contains('bytes') == true, + ); + } finally { + client.close(); + } + } + + /// Non-resumable fallback: used when the server will not honour Range, or the + /// file is small enough that resume is pointless. + Future _downloadSingle({ + required Uri uri, + required String fileName, + }) async { + final client = _newClient(); + try { + final response = await client.send(http.Request('GET', uri)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError('Model download failed: HTTP ${response.statusCode}'); + } + _downloadTotalBytes ??= response.contentLength; + _notify(); + final trackedStream = _trackDownloadProgress(response.stream); + return await _fileStore.writeModelBytes( + fileName: fileName, + chunks: trackedStream, + ); + } finally { + client.close(); + } + } + + /// 8-way parallel, per-chunk resumable. + Future _downloadRanged({ + required Uri uri, + required String fileName, + required int totalSize, + }) async { + final chunkSize = (totalSize / _parallelChunks).ceil(); + final chunkPaths = []; + final clients = []; + try { + final futures = >[]; + for (var i = 0; i < _parallelChunks; i++) { + final start = i * chunkSize; + if (start >= totalSize) break; + final end = (start + chunkSize - 1).clamp(0, totalSize - 1); + final expected = end - start + 1; + + // The chunk key carries totalSize so a file that changed length upstream + // cannot resume onto offsets computed for the old length. Silent + // cross-version splicing is the one failure mode a naive resume has that + // no-resume does not, and it produces a corrupt model with a valid size. + final chunkPath = await _fileStore.chunkFilePath( + '$fileName.$totalSize', + i, + ); + chunkPaths.add(chunkPath); + + var have = await _fileStore.fileSize(chunkPath); + if (have > expected) { + // Only reachable if a previous run wrote past its range. Distrust it. + await _fileStore.deleteFile(chunkPath); + have = 0; + } + _downloadedBytes += have; + if (have == expected) { + continue; // Complete from a previous attempt. + } + + final client = _newClient(); + clients.add(client); + futures.add( + _downloadRange( + client: client, + uri: uri, + chunkPath: chunkPath, + start: start + have, + end: end, + ), + ); + } + _notify(); + await Future.wait(futures); + if (_cancelDownloadRequested) { + throw const ImageCodecDownloadCancelled(); + } + return await _fileStore.combineChunks( + fileName: fileName, + chunkPaths: chunkPaths, + ); + } finally { + for (final client in clients) { + client.close(); + } + // Chunk files are deliberately NOT deleted on failure — they are the + // resume state. `combineChunks` reaps them once the merge succeeds, and + // `deletePartialDownloads` sweeps them after verification. + } + } + + Future _downloadRange({ + required http.Client client, + required Uri uri, + required String chunkPath, + required int start, + required int end, + }) async { + final request = http.Request('GET', uri); + request.headers['Range'] = 'bytes=$start-$end'; + final response = await client.send(request); + if (response.statusCode != 206) { + await response.stream.drain(); + throw StateError( + 'Range download failed: HTTP ${response.statusCode}' + '${response.statusCode == 200 ? ' (server ignored Range header)' : ''}', + ); + } + await _fileStore.appendBytes( + path: chunkPath, + chunks: _trackDownloadProgress(response.stream), + ); + } + + /// True when [path] matches [asset]'s digest, or when there is no digest. + Future _checksumMatches(ImageCodecModelAsset asset, String path) async { + if (!asset.hasChecksum) { + return true; + } + try { + final actual = await _fileStore.sha256OfFile(path); + return actual == asset.sha256.toLowerCase(); + } catch (_) { + return false; + } + } + + /// Verifies [path] and deletes it if the digest is wrong. + /// + /// A no-op when the asset has no digest — which is the case for every preset + /// today, because nothing has been published to hash. That is a real hole: + /// see the note on [ImageCodecModelAsset.sha256]. + Future _verifyChecksum(ImageCodecModelAsset asset, String path) async { + if (!asset.hasChecksum) { + appLogger.warn( + 'No SHA-256 for ${asset.fileName}; integrity unverified', + tag: 'ImageCodec', + ); + return; + } + _downloadFileName = 'Verifying ${asset.fileName}'; + _notify(); + final actual = await _fileStore.sha256OfFile(path); + if (actual != asset.sha256.toLowerCase()) { + await _fileStore.deleteFile(path); + throw ImageCodecIntegrityFailure( + fileName: asset.fileName, + expected: asset.sha256.toLowerCase(), + actual: actual, + ); + } + } + + void cancelDownload() { + if (!_isDownloading) { + return; + } + _cancelDownloadRequested = true; + _lastError = 'Download stopped.'; + _notify(); + } + + Future removeModel(ImageCodecModelRecord model) async { + // Free the isolate first: on Android the file cannot be deleted while the + // native session still has it mapped. + await releaseModel(); + await _runExclusive(() async { + _setBusy(true); + _lastError = null; + await _fileStore.deleteModel(model); + // deleteModel only knows about `localPath` (the 3 MB graph) unless the + // store has been taught about bundles, so sweep the recorded siblings + // here too. Without this, "Remove model" leaves 940 MB on the device. + final directory = _directoryOf(model.localPath); + for (final name in model.assetFileNames) { + final path = '$directory/$name'; + if (path == model.localPath) continue; + await _fileStore.deleteFile(path); + await _fileStore.deletePartialDownloads(name); + } + final updated = preferences.downloadedModels + .where((entry) => entry.id != model.id) + .toList(); + var next = preferences.copyWith(downloadedModels: updated); + if (next.selectedModelId == model.id) { + next = next.copyWith( + selectedModelId: updated.isNotEmpty ? updated.first.id : null, + ); + } + await _settingsStore.save(next); + }); + } + + // ---- codec API ----------------------------------------------------------- + + /// [ImageSendCodec] entry point used by `ImageSendPreviewSheet`. + /// + /// Takes the raw picked file (JPEG/PNG/HEIC/…), centre-crops and scales it to + /// [kImageCodecSquareSize] square, then encodes. Throws on failure so the + /// sheet can surface it — unlike the nullable service-level methods below, + /// whose contract is "null means not available". + @override + Future encode( + Uint8List imageBytes, + ImageCodecRatePoint rate, + ) async { + if (!kImageCodecBitstreamPathAvailable) { + // Fail here rather than after a 512x512 raster and a session load, and + // fail with the specific exception so the sheet can say why. + throw const ImageCodecEntropyPathMissing(); + } + if (installedBundle?.isComplete != true) { + // A different failure with a different remedy: the build can do this, the + // installed files cannot. Do not collapse the two. + throw const ImageCodecBundleIncomplete(); + } + final result = await encodeImage( + rgbBytes: await _toSquareRgb(imageBytes), + ratePoint: aeicRatePointForUi(rate), + ); + final bitstream = result?.bitstream; + if (bitstream == null) { + throw StateError(_lastError ?? 'Image codec is not available.'); + } + return bitstream; + } + + /// Encodes an already-decoded RGB image. + /// + /// [rgbBytes] must be exactly `512 * 512 * 3` packed 8-bit RGB. Anything else + /// is rejected: the decoder's SD-Turbo UNet needs a 64x64 latent, so 512 is a + /// hard floor, not a default. + Future encodeImage({ + required Uint8List rgbBytes, + required AeicRatePoint ratePoint, + }) async { + if (!canEncode) return null; + const expected = + kImageCodecSquareSize * kImageCodecSquareSize * _bytesPerPixel; + if (rgbBytes.length != expected) { + throw ArgumentError.value( + rgbBytes.length, + 'rgbBytes', + 'expected $expected bytes of ${kImageCodecSquareSize}x' + '$kImageCodecSquareSize RGB', + ); + } + return _runCodecJob( + stage: 'Encoding…', + ratePoint: ratePoint, + action: (session) => session.encode( + rgbBytes, + ratePoint, + kImageCodecSquareSize, + onProgress: _reportCodecProgress, + ), + wrap: (bytes, elapsed) => ImageCodecResult( + bitstream: bytes, + ratePoint: ratePoint, + resolution: kImageCodecSquareSize, + durationMs: elapsed, + status: ImageCodecStatus.completed, + ), + ); + } + + /// Encodes a source file, resizing to 512x512 first. + /// + /// Returns null when [canEncode] is false. + Future encodeImageFile({ + required String path, + AeicRatePoint? ratePoint, + }) async { + if (!canEncode) return null; + final bytes = await _fileStore.readFileBytes(path); + return encodeImage( + rgbBytes: await _toSquareRgb(bytes), + ratePoint: ratePoint ?? defaultRatePoint, + ); + } + + /// Decodes a reassembled bitstream back to PNG bytes. + /// + /// [resolution] comes from the chunk-0 metadata byte; a stream announcing a + /// resolution this build cannot decode is rejected rather than guessed at. + Future decodeBitstream({ + required Uint8List bitstream, + required AeicRatePoint ratePoint, + required int resolution, + }) async { + if (!kImageCodecBitstreamPathAvailable) { + // A received image cannot be silently dropped: the receive path has to be + // able to tell the user why nothing rendered. + throw const ImageCodecEntropyPathMissing(); + } + // `supportsDecode`, not `isComplete`: a bundle-version-1 install has the + // send-side entropy graph and can encode, but the decode-side graph is what + // turns bytes back into a latent. Same remedy either way — re-download. + if (installedBundle?.supportsDecode != true) { + throw const ImageCodecBundleIncomplete(); + } + if (!canDecode) return null; + if (bitstream.isEmpty) return null; + if (resolution != kImageCodecSquareSize) { + _lastError = 'Unsupported image resolution: $resolution.'; + _notify(); + return ImageCodecResult( + ratePoint: ratePoint, + resolution: resolution, + durationMs: 0, + status: ImageCodecStatus.failed, + ); + } + // The tables are per-checkpoint. Decoding an ft16 stream against ft32 + // tables does not fail — it desynchronises rANS and yields a sharp, + // plausible, wrong picture — so a rate mismatch is refused up front. It is + // a failed *result*, not a throw: it is a property of the received message, + // not a programming error. + final installedRate = installedBundle?.ratePoint; + if (installedRate != null && installedRate != ratePoint) { + _lastError = + 'This picture was sent at ${ratePoint.name}, but the installed model ' + 'is ${installedRate.name}.'; + _notify(); + return ImageCodecResult( + ratePoint: ratePoint, + resolution: resolution, + durationMs: 0, + status: ImageCodecStatus.failed, + ); + } + final result = await _runCodecJob( + stage: 'Decoding…', + ratePoint: ratePoint, + action: (session) => session.decode( + bitstream, + ratePoint, + resolution, + onProgress: _reportCodecProgress, + ), + // The worker returns packed RGB, exactly like decodeLatent. Labelling it + // `pngBytes` (as this used to) hands the receive path bytes that are not + // a PNG under a name that says they are; `Image.memory` then fails on a + // decode that actually succeeded. + wrap: (bytes, elapsed) => ImageCodecResult( + rgbBytes: bytes, + ratePoint: ratePoint, + resolution: resolution, + durationMs: elapsed, + status: ImageCodecStatus.completed, + ), + ); + final rgb = result?.rgbBytes; + if (result == null || rgb == null) { + return result; + } + // Both forms: RGB for anything that wants pixels, PNG for `Image.memory`. + return ImageCodecResult( + rgbBytes: rgb, + pngBytes: await rgbToPng(rgb, result.resolution), + ratePoint: result.ratePoint, + resolution: result.resolution, + durationMs: result.durationMs, + status: result.status, + ); + } + + /// Runs the synthesis half of the decoder: latent -> packed 8-bit RGB. + /// + /// **This is the only inference path that works in this build.** It is what a + /// device smoke test should call: dump a `y_hat` from + /// `aic/exp/aeic_runner.py`, ship the 65,536 float32s to the phone, and + /// compare the result against the desktop ONNX output. Everything from a + /// bitstream to `y_hat` needs the entropy path — see + /// [ImageCodecEntropyPathMissing]. + /// + /// Returns null when no model is available; the returned result carries + /// [ImageCodecResult.rgbBytes]. + Future decodeLatent(Float32List yHat) async { + if (!canRunInference) return null; + if (yHat.length != kImageCodecLatentElements) { + throw ArgumentError.value( + yHat.length, + 'yHat', + 'expected $kImageCodecLatentElements float32 latents ' + '(shape $kImageCodecLatentShape)', + ); + } + return _runCodecJob( + stage: 'Rendering…', + ratePoint: kShippingAeicRatePoint, + action: (session) => + session.decodeLatent(yHat, onProgress: _reportCodecProgress), + wrap: (bytes, elapsed) => ImageCodecResult( + rgbBytes: bytes, + ratePoint: kShippingAeicRatePoint, + resolution: kImageCodecSquareSize, + durationMs: elapsed, + status: ImageCodecStatus.completed, + ), + ); + } + + /// [decodeLatent] followed by PNG encoding, for anything that renders an + /// `Image.memory`. + Future decodeLatentToPng(Float32List yHat) async { + final result = await decodeLatent(yHat); + final rgb = result?.rgbBytes; + if (result == null || rgb == null) { + return result; + } + return ImageCodecResult( + rgbBytes: rgb, + pngBytes: await rgbToPng(rgb, result.resolution), + ratePoint: result.ratePoint, + resolution: result.resolution, + durationMs: result.durationMs, + status: result.status, + ); + } + + /// Packed 8-bit RGB -> PNG, via `dart:ui` so this adds no dependency. + /// + /// Runs on the calling isolate. A single 512x512 raster is four orders of + /// magnitude cheaper than the 1385 GFLOP decode that produced it. + static Future rgbToPng(Uint8List rgb, int side) async { + final pixels = side * side; + if (rgb.length != pixels * _bytesPerPixel) { + throw ArgumentError.value( + rgb.length, + 'rgb', + 'expected ${pixels * _bytesPerPixel} bytes for ${side}x$side RGB', + ); + } + final rgba = Uint8List(pixels * 4); + for (var i = 0, s = 0, d = 0; i < pixels; i++, s += 3, d += 4) { + rgba[d] = rgb[s]; + rgba[d + 1] = rgb[s + 1]; + rgba[d + 2] = rgb[s + 2]; + rgba[d + 3] = 0xFF; + } + final buffer = await ui.ImmutableBuffer.fromUint8List(rgba); + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: side, + height: side, + pixelFormat: ui.PixelFormat.rgba8888, + ); + ui.Codec? codec; + ui.Image? image; + try { + codec = await descriptor.instantiateCodec(); + image = (await codec.getNextFrame()).image; + final png = await image.toByteData(format: ui.ImageByteFormat.png); + if (png == null) { + throw StateError('Could not PNG-encode the decoded image.'); + } + return png.buffer.asUint8List(); + } finally { + image?.dispose(); + codec?.dispose(); + descriptor.dispose(); + } + } + + /// Cooperative cancel for a long encode/decode, mirroring [cancelDownload]. + /// + /// The worker polls a flag between backend stages; a backend stuck inside one + /// blocking native call will not notice until that call returns. For a hard + /// stop use [handleMemoryPressure] / [releaseModel], which kill the isolate. + void cancelCodecJob() { + final session = _session; + if (session == null || _codecStage == null) return; + _lastError = 'Image processing stopped.'; + session.cancel(); + _notify(); + } + + Future _runCodecJob({ + required String stage, + required AeicRatePoint ratePoint, + required Future Function(ImageCodecSession session) action, + required ImageCodecResult Function(Uint8List bytes, int elapsedMs) wrap, + }) async { + final bundle = installedBundle; + if (bundle == null) return null; + final started = DateTime.now(); + try { + return await _runExclusive(() async { + _setCodecStage('Loading model…', 0); + final session = await _ensureSession(bundle); + if (session == null) { + return null; + } + _setCodecStage(stage, 0); + final bytes = await action(session); + return wrap(bytes, DateTime.now().difference(started).inMilliseconds); + }); + } catch (error) { + _lastError = error.toString(); + appLogger.warn('Image codec job failed: $error', tag: 'ImageCodec'); + return ImageCodecResult( + ratePoint: ratePoint, + resolution: kImageCodecSquareSize, + durationMs: DateTime.now().difference(started).inMilliseconds, + status: ImageCodecStatus.failed, + ); + } finally { + _setCodecStage(null, null); + } + } + + // ---- pixel preparation --------------------------------------------------- + + static const int _bytesPerPixel = 3; + + /// Centre-crops [imageBytes] to a square and scales it to + /// [kImageCodecSquareSize], returning packed 8-bit RGB. + /// + /// Uses `dart:ui` rather than a decode package so this adds no dependency. + /// It runs on the root isolate, which is acceptable: it is a single 512x512 + /// raster, three orders of magnitude cheaper than the 1385 GFLOP inference + /// that follows in the worker. + Future _toSquareRgb(Uint8List imageBytes) async { + final codec = await ui.instantiateImageCodec(imageBytes); + final frame = await codec.getNextFrame(); + final source = frame.image; + ui.Image? scaled; + try { + // The WHOLE frame, stretched to the square — not a centre crop. + // Aspect ratio is deliberately not preserved: the codec's graph is + // statically 512x512, so something has to give, and losing 25% of a 4:3 + // photo (44% of 16:9, or the subject's head in a portrait) is worse than + // distorting it. Nothing outside the frame is discarded this way. + // NOTE: the aspect ratio is NOT transmitted, so the receiver cannot undo + // the stretch — see the note in image_chunk_transport.dart on the + // metadata byte. + final src = ui.Rect.fromLTWH( + 0, + 0, + source.width.toDouble(), + source.height.toDouble(), + ); + final dstSize = kImageCodecSquareSize.toDouble(); + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawImageRect( + source, + src, + ui.Rect.fromLTWH(0, 0, dstSize, dstSize), + ui.Paint()..filterQuality = ui.FilterQuality.medium, + ); + final picture = recorder.endRecording(); + try { + scaled = await picture.toImage( + kImageCodecSquareSize, + kImageCodecSquareSize, + ); + } finally { + picture.dispose(); + } + final rgba = await scaled.toByteData(format: ui.ImageByteFormat.rawRgba); + if (rgba == null) { + throw StateError('Could not rasterise the image.'); + } + return _rgbaToRgb(rgba.buffer.asUint8List()); + } finally { + scaled?.dispose(); + source.dispose(); + codec.dispose(); + } + } + + static Uint8List _rgbaToRgb(Uint8List rgba) { + final pixels = rgba.length ~/ 4; + final rgb = Uint8List(pixels * _bytesPerPixel); + for (var i = 0, o = 0; i < rgba.length; i += 4, o += _bytesPerPixel) { + rgb[o] = rgba[i]; + rgb[o + 1] = rgba[i + 1]; + rgb[o + 2] = rgba[i + 2]; + } + return rgb; + } + + // ---- model memory (mirrors translation_service.dart:586-629) ------------- + + Future _ensureSession(ImageCodecBundle bundle) async { + if (_session != null && _loadedBundle == bundle) { + return _session; + } + if (bundle == _failedBundle) { + return null; + } + final previous = _session; + if (previous != null) { + _session = null; + _loadedBundle = null; + await previous.dispose(); + } + try { + final session = await ImageCodecSession.spawn(bundle); + _session = session; + _loadedBundle = bundle; + _failedBundle = null; + return session; + } on ImageCodecUnimplemented catch (error) { + // No inference runtime in this build; retrying can never help, so poison + // the whole feature rather than just this path. + _backendMissing = true; + _failedBundle = bundle; + _lastError = error.toString(); + appLogger.warn('Image codec unavailable: $error', tag: 'ImageCodec'); + _notify(); + return null; + } catch (_) { + _failedBundle = bundle; + rethrow; + } + } + + Future releaseModel() async { + await _runExclusive(() async { + final session = _session; + if (session == null) { + _loadedBundle = null; + return; + } + _session = null; + _loadedBundle = null; + await session.dispose(); + }); + } + + /// Drops the ~2.16 GiB image decoder session, keeping the isolate and the + /// ~0.35 GiB entropy session alive. + /// + /// This is the cheap eviction: measured, 2.44 GiB resident falls to 0.35 GiB, + /// and it leaves the send path warm because encoding never touches the image + /// decoder. Safe to call when nothing is loaded. + Future releaseDecoderSession() async { + final session = _session; + if (session == null) return; + await session.release(decoder: true); + appLogger.info('Image codec decoder session released', tag: 'ImageCodec'); + } + + /// Drops the entropy session (whichever direction is resident), keeping the + /// image decoder. + /// + /// Rarely what you want on its own — `decode()` does it internally between + /// the entropy half and the synthesis half — and it is the WRONG half to shed + /// under memory pressure: it frees 0.35 GiB and costs the send path its warm + /// session. Exposed only so a host that knows no send is coming can shed it. + Future releaseEntropySession() async { + final session = _session; + if (session == null) return; + await session.release(entropy: true); + } + + /// Sheds codec memory under pressure. + /// + /// Not present in [TranslationService], and required here: the decode graph + /// peaks around 2.16 GiB resident, far above what Android will let a + /// backgrounded app keep. Wire it to an app-level [WidgetsBindingObserver] — + /// `didChangeAppLifecycleState` (paused/hidden) and `didHaveMemoryPressure`. + /// Idempotent and safe when nothing is loaded. + /// + /// Order matters: the IMAGE DECODER goes first, always. Measured, it is + /// 2.16 GiB of a 2.44 GiB peak — 89% of the resident set — and it is the half + /// the *send* path does not need. On a soft eviction ([keepEntropySession]) + /// the 0.35 GiB entropy graph is deliberately kept, so a user who backgrounds + /// the app and comes back to send a photo pays nothing while the big half is + /// already gone. Never invert this: dropping the entropy graph first would + /// free an eighth of the memory and cost the only path that was warm. + /// + /// The default is still the hard stop — kill the isolate — because that also + /// returns ORT's arena and any native allocation the plugin is holding, which + /// closing sessions alone does not. + Future handleMemoryPressure({bool keepEntropySession = false}) async { + final session = _session; + if (session == null && _loadedBundle == null) { + return; + } + // Deliberately does NOT go through _runExclusive: memory pressure arrives + // while a decode may be mid-flight and holding the lock, and that decode is + // exactly what has to die. Killing the isolate fails its pending futures, + // which _runCodecJob reports as a failed result. + if (keepEntropySession && session != null) { + await session.release(decoder: true); + appLogger.info( + 'Image codec decoder evicted (entropy session kept)', + tag: 'ImageCodec', + ); + _notify(); + return; + } + _session = null; + _loadedBundle = null; + _codecProgress = null; + _codecStage = null; + if (session != null) { + // Ask for the big half back before the shutdown handshake, so the peak + // does not have to wait on isolate teardown. + await session.release(decoder: true); + await session.dispose(); + } + appLogger.info('Image codec model evicted', tag: 'ImageCodec'); + _notify(); + } + + // ---- plumbing (copied from translation_service.dart:631-698) ------------ + + Future _runExclusive(Future Function() action) { + final completer = Completer(); + _setBusy(true); + _queue = _queue.then((_) async { + if (_disposed) { + completer.completeError(StateError('ImageCodecService disposed.')); + return; + } + try { + completer.complete(await action()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + _setBusy(false); + } + }); + return completer.future; + } + + Stream> _trackDownloadProgress(Stream> source) async* { + await for (final chunk in source) { + if (_cancelDownloadRequested) { + throw const ImageCodecDownloadCancelled(); + } + _downloadedBytes += chunk.length; + _notify(); + yield chunk; + } + } + + void _reportCodecProgress(double value) { + _codecProgress = value.clamp(0.0, 1.0); + _notify(); + } + + void _setCodecStage(String? stage, double? progress) { + _codecStage = stage; + _codecProgress = progress; + _notify(); + } + + void _notify() { + if (_disposed) { + return; + } + notifyListeners(); + } + + void _setBusy(bool value) { + if (_isBusy == value) { + return; + } + _isBusy = value; + _notify(); + } + + void _setDownloading(bool value) { + _isDownloading = value; + if (!value) { + _cancelDownloadRequested = false; + _downloadedBytes = 0; + _downloadTotalBytes = null; + _downloadFileName = null; + } + _notify(); + } + + /// Sweeps resume state for a model the user is no longer downloading. + /// + /// Spec-shaped rather than filename-shaped: a bundle leaves chunk files for + /// up to four assets, and sweeping only the graph's would strand ~900 MB of + /// hidden resume state that nothing ever collects. + Future discardPartialDownload(ImageCodecModelSpec spec) async { + for (final asset in spec.assets) { + await _fileStore.deletePartialDownloads(asset.fileName); + } + } + + /// Sweeps resume state for one file, for the custom-URL install path. + Future discardPartialDownloadFile(String fileName) => + _fileStore.deletePartialDownloads(fileName); + + String _sanitizeFileName(String fileName) { + final cleaned = fileName.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + return cleaned.isEmpty ? 'image-codec-model.onnx' : cleaned; + } + + @override + void dispose() { + _disposed = true; + final session = _session; + _session = null; + _loadedBundle = null; + if (session != null) { + unawaited(session.dispose()); + } + super.dispose(); + } +} + +/// What a HEAD probe told us about a model asset. +class _HeadResult { + final int? contentLength; + final bool supportsRange; + + const _HeadResult({required this.contentLength, required this.supportsRange}); +} diff --git a/lib/services/image_codec_session.dart b/lib/services/image_codec_session.dart new file mode 100644 index 00000000..d1a437a5 --- /dev/null +++ b/lib/services/image_codec_session.dart @@ -0,0 +1,2 @@ +export 'image_codec_session_stub.dart' + if (dart.library.io) 'image_codec_session_io.dart'; diff --git a/lib/services/image_codec_session_io.dart b/lib/services/image_codec_session_io.dart new file mode 100644 index 00000000..2a1bb57f --- /dev/null +++ b/lib/services/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 _pending = {}; + final Map> _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 spawn(ImageCodecBundle bundle) async { + final fromWorker = ReceivePort(); + final errors = ReceivePort(); + final handshake = Completer>(); + ImageCodecSession? session; + + fromWorker.listen((message) { + if (message is! Map) return; + final map = message.cast(); + 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>( + _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 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 decodeLatent( + Float32List yHat, { + void Function(double progress)? onProgress, + }) { + return _submit( + op: 'decodeLatent', + latents: yHat, + ratePoint: kShippingAeicRatePoint, + resolution: kImageCodecSquareSize, + onProgress: onProgress, + ); + } + + Future 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 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 _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({ + '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 release({bool decoder = false, bool entropy = false}) { + if (!decoder && !entropy) return Future.value(); + if (_disposed) return Future.value(); + final id = _nextJobId++; + final completer = Completer(); + _pendingReleases[id] = completer; + _toWorker.send({ + '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 {'type': 'cancel'}); + } + + void _handleMessage(Map 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 dispose() async { + if (_disposed) return; + _disposed = true; + _toWorker.send(const {'type': 'shutdown'}); + // Give the worker one event-loop turn to release the native session + // cleanly, then take the memory back regardless. + await Future.delayed(const Duration(milliseconds: 50)); + _isolate.kill(priority: Isolate.immediate); + _failAll(StateError('Image codec session disposed.')); + _fromWorker.close(); + _errors.close(); + } +} + +class _PendingJob { + final Completer completer = Completer(); + 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 _bootPayload( + ImageCodecBundle bundle, + SendPort reply, + RootIsolateToken? rootToken, +) => [ + 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 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 debugBootPayloadFor(ImageCodecBundle bundle) => + _bootPayload(bundle, _NullSendPort(), null); + +@visibleForTesting +ImageCodecBundle debugBundleFromBootPayload(List 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 _codecWorkerMain(List boot) async { + final reply = boot[0] as SendPort; + final bundle = _bundleFromBootPayload(boot); + final rootToken = boot[5] as RootIsolateToken?; + + if (rootToken == null) { + reply.send({ + '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({ + '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({'type': 'fatal', 'error': error.toString()}); + await backend.dispose(); + return; + } + + final commands = ReceivePort(); + var cancelRequested = false; + var queue = Future.value(); + + reply.send({ + '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(); + 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({'type': 'released', 'id': id}); + } catch (error) { + reply.send({ + '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({ + '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({ + 'type': 'result', + 'id': id, + 'bytes': result, + }); + } catch (error, stackTrace) { + reply.send({ + 'type': 'error', + 'id': id, + 'error': error.toString(), + 'stack': stackTrace.toString(), + }); + } + }); + } + }); +} diff --git a/lib/services/image_codec_session_stub.dart b/lib/services/image_codec_session_stub.dart new file mode 100644 index 00000000..756775e3 --- /dev/null +++ b/lib/services/image_codec_session_stub.dart @@ -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 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 release({bool decoder = false, bool entropy = false}) async {} + + Future decodeLatent( + Float32List yHat, { + void Function(double progress)? onProgress, + }) async { + throw UnsupportedError('The image codec is not supported on web.'); + } + + Future encode( + Uint8List rgbBytes, + AeicRatePoint ratePoint, + int resolution, { + void Function(double progress)? onProgress, + }) async { + throw UnsupportedError('The image codec is not supported on web.'); + } + + Future 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 dispose() async {} +} diff --git a/lib/services/image_codec_settings_store.dart b/lib/services/image_codec_settings_store.dart new file mode 100644 index 00000000..55e9376f --- /dev/null +++ b/lib/services/image_codec_settings_store.dart @@ -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 load(); + + Future 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 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) { + _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 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 load() async {} + + @override + Future save(ImageCodecPreferences preferences) async { + _preferences = preferences; + } +} diff --git a/lib/services/rans_coder.dart b/lib/services/rans_coder.dart new file mode 100644 index 00000000..5709ab10 --- /dev/null +++ b/lib/services/rans_coder.dart @@ -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 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 sizes = []; + 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 parts = []; + 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 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 symbols, + List 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 symbols, + List 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 parts = [ + 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.filled(_parts.length, 0); + _ptrs = List.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 _parts; + late final List _states; + late final List _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 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; + } +} diff --git a/lib/services/received_image_blob_store_factory.dart b/lib/services/received_image_blob_store_factory.dart new file mode 100644 index 00000000..5b8a646b --- /dev/null +++ b/lib/services/received_image_blob_store_factory.dart @@ -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'; diff --git a/lib/services/received_image_blob_store_factory_io.dart b/lib/services/received_image_blob_store_factory_io.dart new file mode 100644 index 00000000..6c24d733 --- /dev/null +++ b/lib/services/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(); diff --git a/lib/services/received_image_blob_store_factory_stub.dart b/lib/services/received_image_blob_store_factory_stub.dart new file mode 100644 index 00000000..34fcdca5 --- /dev/null +++ b/lib/services/received_image_blob_store_factory_stub.dart @@ -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(); diff --git a/lib/services/received_image_blob_store_io.dart b/lib/services/received_image_blob_store_io.dart new file mode 100644 index 00000000..42bc18b6 --- /dev/null +++ b/lib/services/received_image_blob_store_io.dart @@ -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 +/// +/// ``` +/// /received_images/ +/// .aeic the ~156 B bitstream +/// .png the decoded 512x512 PNG (~400 KB) +/// .json the sidecar record (ReceivedImageEntry.toJson) +/// .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 `.json.tmp` but never a truncated `.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 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? _pending; + + FileReceivedImageBlobStore({ + Future 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 ensureReady() { + final cached = _dirPath; + if (cached != null) return Future.value(cached); + final pending = _pending; + if (pending != null) return pending; + final started = _resolve(); + _pending = started; + return started.whenComplete(() => _pending = null); + } + + Future _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 _pathFor(String streamId, String extension) async { + if (!_safeId.hasMatch(streamId)) return null; + final dir = await ensureReady(); + return '$dir${Platform.pathSeparator}$streamId$extension'; + } + + Future _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 _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 _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 _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 writeBitstream(String streamId, Uint8List bytes) => + _write(streamId, _bitstreamExt, bytes); + + @override + Future readBitstream(String streamId) => + _read(streamId, _bitstreamExt); + + @override + Future deleteBitstream(String streamId) => + _delete(streamId, _bitstreamExt); + + @override + Future bitstreamSize(String streamId) => _size(streamId, _bitstreamExt); + + @override + Future writePng(String streamId, Uint8List bytes) => + _write(streamId, _pngExt, bytes); + + @override + Future readPng(String streamId) => _read(streamId, _pngExt); + + @override + Future deletePng(String streamId) => _delete(streamId, _pngExt); + + @override + Future pngSize(String streamId) => _size(streamId, _pngExt); + + /// Atomic: write `.json.tmp`, then rename over `.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 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 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> readSidecars() async { + final result = {}; + 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 = []; + try { + files.addAll(dir.listSync().whereType()); + } catch (error) { + debugPrint('received_image_blob_store: list failed: $error'); + return result; + } + + final orphans = []; + 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'; + } +} diff --git a/lib/services/received_image_store.dart b/lib/services/received_image_store.dart new file mode 100644 index 00000000..2258fd7d --- /dev/null +++ b/lib/services/received_image_store.dart @@ -0,0 +1,1487 @@ +/// Receive-side state, persistence and decode scheduling for AEIC images that +/// arrive over `PAYLOAD_TYPE_GRP_DATA` (see `image_chunk_transport.dart`). +/// +/// This file holds the whole receive model: +/// * [ReceivedImageRef] the sentinel that a channel message carries in its +/// `text` field instead of the pixels. +/// * [ReceivedImageState] the state machine every incoming image walks. +/// * [ReceivedImageEntry] one immutable snapshot of an image's state. +/// * [ReceivedImageStore] the `ChangeNotifier` the message list listens to: +/// intake, persistence, eviction and the decode queue. +/// +/// ## Deliberate dependency shape +/// +/// There is no `dart:io` here and no `package:flutter/material.dart`. Bytes go +/// through the [ReceivedImageBlobStore] seam (default: +/// [InMemoryReceivedImageBlobStore]) and decoding goes through the +/// [ReceivedImageDecoder] seam, which `ImageCodecService` satisfies +/// member-for-member (the wiring agent supplies the one-class adapter — this +/// file deliberately does not import the service, so a broken edit over there +/// cannot break the receive tests). That keeps the store unit-testable with no +/// temp directories, no `path_provider` and no 872 MB model, and it keeps the +/// file compilable on web where neither seam has an implementation. +/// +/// `FileReceivedImageBlobStore` (`received_image_blob_store_io.dart`) is the +/// file-backed implementation; pass it as `blobs:` and received images survive +/// a restart. With the default in-memory store everything below works exactly +/// as specified but forgets its bytes on restart. Nothing else changes. +/// +/// ## Decode policy (four gates) +/// +/// The bitstream is ~156 B and already in hand, so decoding is only ever gated +/// on memory, never on bandwidth. Gates, all enforced here: +/// 0. [processAutomatically] — when false, an arrival is parked as +/// `reassembled + needsManualDecode` and waits +/// for a tap. Mirrors the "Process images +/// automatically" setting, which ships OFF. +/// 1. foreground only — [setForeground]; background decode is a ~2.7 GiB +/// peak and an OOM kill waiting to happen. +/// 2. strictly one at a time — and never while the codec is busy encoding for +/// the compose UI. +/// 3. burst cap — at most [burstCap] auto-decodes are queued; the +/// rest wait for a tap ([ReceivedImageEntry.needsManualDecode]). +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../models/image_codec_support.dart'; +import '../widgets/image_send_codec_binding.dart'; +import 'image_chunk_transport.dart'; + +// --------------------------------------------------------------------------- +// Sentinel +// --------------------------------------------------------------------------- + +/// Encodes/parses the sentinel string a `ChannelMessage.text` carries in place +/// of an image. +/// +/// Mirrors `GifHelper.encodeGif` (`lib/helpers/gif_helper.dart`): the message +/// record stays a short string, the bytes live beside it. The sentinel never +/// changes for the life of the image, so `ChannelMessage`'s `text.hashCode` +/// derived id stays stable and a per-chunk state change does not rewrite the +/// channel's whole prefs blob. +class ReceivedImageRef { + ReceivedImageRef._(); + + /// Versioned on purpose: a future wire-format change ships `aeic:2:`. + static const String scheme = 'aeic:1:'; + + static final RegExp _pattern = RegExp(r'^aeic:1:([0-9a-f]{14})$'); + + static String encode(String streamId) => '$scheme$streamId'; + + /// Returns the stream id, or null when [text] is not an image sentinel. + static String? parse(String text) => _pattern.firstMatch(text.trim())?.group(1); + + /// `%04x%02x%08x` — 4 hex sender prefix, 2 hex img id, 8 hex epoch seconds. + /// + /// 14 lowercase hex characters, unique in practice, and it doubles as the + /// on-disk filename stem. + static String streamIdFor({ + required int senderPrefix, + required int imgId, + required DateTime firstSeen, + }) { + final seconds = firstSeen.millisecondsSinceEpoch ~/ 1000; + return (senderPrefix & 0xFFFF).toRadixString(16).padLeft(4, '0') + + (imgId & 0xFF).toRadixString(16).padLeft(2, '0') + + (seconds & 0xFFFFFFFF).toRadixString(16).padLeft(8, '0'); + } +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// Lifecycle of one received image. Legal transitions are enforced by +/// [ReceivedImageStore]; see the class doc there. +enum ReceivedImageState { + /// Chunks are arriving; nothing is on disk yet. + receiving, + + /// The bitstream is complete and persisted, but not decoded. + reassembled, + + /// A decode is running right now. + decoding, + + /// A PNG exists. This is the ONLY state that shows synthesized pixels, and + /// therefore the only state that carries the R6 provenance label. + decoded, + + /// The stream expired with chunks missing. Never retryable — the partial + /// bodies only ever lived in the reassembler's memory. + failedIncomplete, + + /// The bitstream arrived whole but the decoder rejected it. User-retryable. + failedCorrupt, + + /// No usable decoder (feature off, no weights, web, or no backend). + decoderUnavailable, + + /// The PNG was reclaimed by the disk budget. Re-decodable while the + /// bitstream survives. + evicted, +} + +extension ReceivedImageStateValue on ReceivedImageState { + String get value => name; + + /// True when the UI must show the R6 synthesized-content label. + bool get showsSynthesizedPixels => this == ReceivedImageState.decoded; + + bool get isFailure => + this == ReceivedImageState.failedIncomplete || + this == ReceivedImageState.failedCorrupt; +} + +ReceivedImageState _parseState(Object? raw) { + if (raw is String) { + for (final state in ReceivedImageState.values) { + if (state.name == raw) return state; + } + } + return ReceivedImageState.failedIncomplete; +} + +/// Immutable snapshot of one image. Handed to the widget layer; every mutation +/// goes through [ReceivedImageStore]. +@immutable +class ReceivedImageEntry { + /// 14 hex chars; see [ReceivedImageRef.streamIdFor]. Also the filename stem. + final String streamId; + + final int senderPrefix; + final int imgId; + final int channelIndex; + + /// Local clock at the first chunk — code 27 carries no timestamp. + final DateTime firstSeen; + + final ReceivedImageState state; + + /// Data chunks accepted so far (parity chunks never counted). + final int receivedChunks; + + /// Data chunks the sender announced (`total` in the chunk header). + final int totalChunks; + + final AeicRatePoint rate; + final int resolution; + + /// Index into [kImageAspectCodes]: the shape the sender's photo was before + /// the codec stretched it into a square. Used to letterbox back on render. + /// Defaults to 0 (square) when the sender said nothing. + final int aspectCode; + + /// The source `width / height` to render at, or null to render square. + double? get displayAspectRatio { + final e = kImageAspectCodes[aspectCode & 0x0F]; + final r = e[0] / e[1]; + return r == 1.0 ? null : r; + } + + /// True when chunk 0's metadata byte was unknown/unavailable and [rate] and + /// [resolution] are the shipped defaults rather than the sender's word. + final bool metadataAssumed; + + /// One chunk was rebuilt from the XOR parity chunk. + final bool recoveredWithParity; + + /// This is the local user's own send. Its PNG is their real 512x512 crop, so + /// [synthesized] is false and the R6 label must NOT be shown. + final bool isOutgoing; + + /// Whether the pixels are generative-model output. Derived, never supplied: + /// `synthesized == !isOutgoing`. + bool get synthesized => !isOutgoing; + + final int? decodeMs; + final String? error; + + /// Auto-decode was skipped (burst cap); the bubble offers "Tap to decode". + final bool needsManualDecode; + + final bool bitstreamStored; + final bool pngStored; + final int bitstreamByteCount; + final int pngByteCount; + + /// Decoded PNG, cached in memory once read. Not persisted in the sidecar and + /// not part of [==]; callers must not hand these bytes onward without the R6 + /// caption (see `received_image_message.dart`). + final Uint8List? pngBytes; + + const ReceivedImageEntry({ + required this.streamId, + required this.senderPrefix, + required this.imgId, + required this.channelIndex, + required this.firstSeen, + required this.state, + required this.receivedChunks, + required this.totalChunks, + this.rate = AeicRatePoint.ft32, + this.resolution = kImageCodecSquareSize, + this.aspectCode = 0, + this.metadataAssumed = true, + this.recoveredWithParity = false, + this.isOutgoing = false, + this.decodeMs, + this.error, + this.needsManualDecode = false, + this.bitstreamStored = false, + this.pngStored = false, + this.bitstreamByteCount = 0, + this.pngByteCount = 0, + this.pngBytes, + }); + + ImageStreamKey get key => ImageStreamKey( + senderPrefix: senderPrefix, + imgId: imgId, + channelIndex: channelIndex, + ); + + /// Bytes this entry is currently costing on disk. + int get storedBytes => + (bitstreamStored ? bitstreamByteCount : 0) + + (pngStored ? pngByteCount : 0); + + /// `evicted`/`failedCorrupt` can be decoded again only while the ~156 B + /// bitstream survives. + bool get canRetryDecode => bitstreamStored; + + static const Object _unset = Object(); + + ReceivedImageEntry copyWith({ + ReceivedImageState? state, + int? receivedChunks, + int? totalChunks, + AeicRatePoint? rate, + int? resolution, + int? aspectCode, + bool? metadataAssumed, + bool? recoveredWithParity, + Object? decodeMs = _unset, + Object? error = _unset, + bool? needsManualDecode, + bool? bitstreamStored, + bool? pngStored, + int? bitstreamByteCount, + int? pngByteCount, + Object? pngBytes = _unset, + }) { + return ReceivedImageEntry( + streamId: streamId, + senderPrefix: senderPrefix, + imgId: imgId, + channelIndex: channelIndex, + firstSeen: firstSeen, + state: state ?? this.state, + receivedChunks: receivedChunks ?? this.receivedChunks, + totalChunks: totalChunks ?? this.totalChunks, + rate: rate ?? this.rate, + resolution: resolution ?? this.resolution, + aspectCode: aspectCode ?? this.aspectCode, + metadataAssumed: metadataAssumed ?? this.metadataAssumed, + recoveredWithParity: recoveredWithParity ?? this.recoveredWithParity, + isOutgoing: isOutgoing, + decodeMs: identical(decodeMs, _unset) ? this.decodeMs : decodeMs as int?, + error: identical(error, _unset) ? this.error : error as String?, + needsManualDecode: needsManualDecode ?? this.needsManualDecode, + bitstreamStored: bitstreamStored ?? this.bitstreamStored, + pngStored: pngStored ?? this.pngStored, + bitstreamByteCount: bitstreamByteCount ?? this.bitstreamByteCount, + pngByteCount: pngByteCount ?? this.pngByteCount, + pngBytes: identical(pngBytes, _unset) + ? this.pngBytes + : pngBytes as Uint8List?, + ); + } + + Map toJson() => { + 'streamId': streamId, + 'senderPrefix': senderPrefix, + 'imgId': imgId, + 'channelIndex': channelIndex, + 'firstSeenMs': firstSeen.millisecondsSinceEpoch, + 'state': state.name, + 'receivedChunks': receivedChunks, + 'totalChunks': totalChunks, + 'rate': rate.wireValue, + 'resolution': resolution, + 'aspect_code': aspectCode, + 'metadataAssumed': metadataAssumed, + 'recoveredWithParity': recoveredWithParity, + 'isOutgoing': isOutgoing, + 'synthesized': synthesized, + 'needsManualDecode': needsManualDecode, + 'bitstreamStored': bitstreamStored, + 'pngStored': pngStored, + 'bitstreamByteCount': bitstreamByteCount, + 'pngByteCount': pngByteCount, + if (decodeMs != null) 'decodeMs': decodeMs, + if (error != null) 'error': error, + }; + + factory ReceivedImageEntry.fromJson(Map json) { + return ReceivedImageEntry( + streamId: json['streamId'] as String? ?? '', + senderPrefix: json['senderPrefix'] as int? ?? 0, + imgId: json['imgId'] as int? ?? 0, + channelIndex: json['channelIndex'] as int? ?? 0, + firstSeen: DateTime.fromMillisecondsSinceEpoch( + json['firstSeenMs'] as int? ?? 0, + ), + state: _parseState(json['state']), + receivedChunks: json['receivedChunks'] as int? ?? 0, + totalChunks: json['totalChunks'] as int? ?? 1, + rate: parseAeicRatePoint(json['rate'] as int? ?? 4), + resolution: json['resolution'] as int? ?? kImageCodecSquareSize, + aspectCode: json['aspect_code'] as int? ?? 0, + metadataAssumed: json['metadataAssumed'] as bool? ?? true, + recoveredWithParity: json['recoveredWithParity'] as bool? ?? false, + isOutgoing: json['isOutgoing'] as bool? ?? false, + decodeMs: json['decodeMs'] as int?, + error: json['error'] as String?, + needsManualDecode: json['needsManualDecode'] as bool? ?? false, + bitstreamStored: json['bitstreamStored'] as bool? ?? false, + pngStored: json['pngStored'] as bool? ?? false, + bitstreamByteCount: json['bitstreamByteCount'] as int? ?? 0, + pngByteCount: json['pngByteCount'] as int? ?? 0, + ); + } + + @override + String toString() => + 'ReceivedImageEntry($streamId, ${state.name}, ' + '$receivedChunks/$totalChunks, ${rate.name})'; +} + +// --------------------------------------------------------------------------- +// Byte persistence seam +// --------------------------------------------------------------------------- + +/// Where the three files of an image live. One implementation per platform. +/// +/// Layout the file-backed implementation uses (application *support* dir, not +/// documents — these are derived caches, not user files): +/// `received_images/.aeic|.png|.json`. See +/// `received_image_blob_store_io.dart`. +abstract class ReceivedImageBlobStore { + Future writeBitstream(String streamId, Uint8List bytes); + Future readBitstream(String streamId); + Future deleteBitstream(String streamId); + + Future writePng(String streamId, Uint8List bytes); + Future readPng(String streamId); + Future deletePng(String streamId); + + /// Byte length of the stored bitstream, or null when there is none. + /// + /// Concrete on purpose: [load] only needs to know whether the bytes exist and + /// how big they are, and the default below would read every 400 KB PNG on the + /// device into memory at startup (200 images => ~80 MB of pointless I/O) just + /// to call `.length` on it. A file-backed store overrides both with `stat`. + Future bitstreamSize(String streamId) async => + (await readBitstream(streamId))?.length; + + /// Byte length of the stored PNG, or null when there is none. + Future pngSize(String streamId) async => (await readPng(streamId))?.length; + + /// Sidecar write must be atomic (tmp + rename) so a kill cannot leave a + /// half-written record that `readSidecars` then discards. + Future writeSidecar(String streamId, String json); + Future deleteSidecar(String streamId); + + /// streamId -> raw sidecar JSON, for the startup scan. + Future> readSidecars(); + + /// Absolute path of the PNG, or null where the platform has no file system. + String? pngPath(String streamId); +} + +/// Default implementation: keeps everything in RAM. +/// +/// Used by tests, by web (where there is no decoder either, so nothing ever +/// reaches [ReceivedImageState.decoded]) and as the fallback until the +/// file-backed store is wired in. +class InMemoryReceivedImageBlobStore implements ReceivedImageBlobStore { + final Map _bitstreams = {}; + final Map _pngs = {}; + final Map _sidecars = {}; + + @override + Future writeBitstream(String streamId, Uint8List bytes) async { + _bitstreams[streamId] = bytes; + } + + @override + Future readBitstream(String streamId) async => + _bitstreams[streamId]; + + @override + Future deleteBitstream(String streamId) async { + _bitstreams.remove(streamId); + } + + @override + Future bitstreamSize(String streamId) async => + _bitstreams[streamId]?.length; + + @override + Future pngSize(String streamId) async => _pngs[streamId]?.length; + + @override + Future writePng(String streamId, Uint8List bytes) async { + _pngs[streamId] = bytes; + } + + @override + Future readPng(String streamId) async => _pngs[streamId]; + + @override + Future deletePng(String streamId) async { + _pngs.remove(streamId); + } + + @override + Future writeSidecar(String streamId, String json) async { + _sidecars[streamId] = json; + } + + @override + Future deleteSidecar(String streamId) async { + _sidecars.remove(streamId); + } + + @override + Future> readSidecars() async => + Map.from(_sidecars); + + @override + String? pngPath(String streamId) => null; + + // Test/debug helpers. + bool hasBitstream(String streamId) => _bitstreams.containsKey(streamId); + bool hasPng(String streamId) => _pngs.containsKey(streamId); + bool hasSidecar(String streamId) => _sidecars.containsKey(streamId); +} + +// --------------------------------------------------------------------------- +// Decoder seam +// --------------------------------------------------------------------------- + +/// The slice of `ImageCodecService` the receive path needs. +/// +/// `ImageCodecService` satisfies this shape member-for-member, so the adapter +/// the wiring agent writes is pure delegation: +/// +/// ```dart +/// 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 decodeBitstream({ +/// required Uint8List bitstream, +/// required AeicRatePoint ratePoint, +/// required int resolution, +/// }) => service.decodeBitstream( +/// bitstream: bitstream, ratePoint: ratePoint, resolution: resolution); +/// @override void cancelCodecJob() => service.cancelCodecJob(); +/// } +/// ``` +/// +/// The interface also exists so the store can be tested without a 872 MB ONNX +/// graph. +abstract class ReceivedImageDecoder { + ImageCodecAvailability get availability; + + /// True while an encode (compose UI) or another decode is running. + bool get isBusy; + + /// Returns null when the codec cannot decode at all; a result with + /// `status == failed` when the bitstream was rejected. + Future decodeBitstream({ + required Uint8List bitstream, + required AeicRatePoint ratePoint, + required int resolution, + }); + + /// Cooperative cancel, used when we background mid-decode. + void cancelCodecJob(); +} + +// --------------------------------------------------------------------------- +// The store +// --------------------------------------------------------------------------- + +/// Owns every received image: its state, its bytes, its place in the decode +/// queue and its share of the disk budget. +/// +/// Legal transitions (nothing else is performed): +/// ``` +/// (none) -> receiving first non-duplicate chunk +/// receiving -> receiving another data chunk +/// receiving -> reassembled completed; bitstream written FIRST +/// receiving -> failedIncomplete TTL/overflow eviction +/// reassembled -> decoding dequeued +/// reassembled -> decoderUnavailable no usable decoder at dequeue +/// decoding -> decoded completed && pngBytes != null +/// decoding -> failedCorrupt failed / throw / bad resolution +/// decoding -> decoderUnavailable decodeBitstream returned null +/// decoding -> reassembled cancelled (background / pressure) +/// decoderUnavailable -> decoding retry, or availability recovered +/// failedCorrupt -> decoding explicit user retry only +/// decoded -> evicted budget reclaimed the PNG +/// evicted -> decoding "Decode again", bitstream survives +/// ``` +/// `failedIncomplete` is terminal: the partial bodies are gone. +class ReceivedImageStore extends ChangeNotifier { + final ReceivedImageBlobStore blobs; + ReceivedImageDecoder? decoder; + + /// Hard cap on stored PNGs. + final int maxImages; + + /// Hard cap on total bytes across all stored files. + final int maxBytes; + + /// Images older than this are dropped from disk entirely. + final Duration maxAge; + + /// How many auto-decodes may be queued before the rest need a tap. + final int burstCap; + + /// When false, a completed bitstream is parked as + /// `reassembled + needsManualDecode` and waits for a tap. Mirrors the + /// "Process images automatically" app setting. + /// + /// Defaults to TRUE at the store level on purpose: that is this class's + /// documented eager contract and what its unit tests assert. Production is + /// off-by-default because `main.dart` passes the (false-by-default) setting + /// explicitly and re-assigns this field whenever the setting changes. + /// + /// Assignment affects FUTURE arrivals only. It deliberately does not kick the + /// queue and does not backfill: turning the setting on must not retro-decode + /// a backlog, which would be an unbounded burst of ~2.16 GiB jobs. + bool processAutomatically; + + final DateTime Function() _clock; + + final Map _entries = {}; + final Map _byKey = {}; + final Map> _listenables = + >{}; + + final List _queue = []; + Future? _pump; + + /// Set synchronously for as long as [_drain]'s body is running. A plain + /// `_pump != null` check is NOT enough: the future stays non-null for one + /// extra microtask after the body has finished, and a chunk arriving in that + /// window used to be dropped on the floor with the queue non-empty and + /// nothing running. + bool _draining = false; + + /// True when the queue stopped because the codec was busy encoding. Cleared + /// by anything that could plausibly have freed it. + bool _parkedOnBusyDecoder = false; + bool _foreground = true; + bool _disposed = false; + + ReceivedImageStore({ + ReceivedImageBlobStore? blobs, + this.decoder, + this.maxImages = 200, + this.maxBytes = 64 * 1024 * 1024, + this.maxAge = const Duration(days: 30), + this.burstCap = 3, + this.processAutomatically = true, + DateTime Function()? clock, + }) : blobs = blobs ?? InMemoryReceivedImageBlobStore(), + _clock = clock ?? DateTime.now; + + // ---- reads --------------------------------------------------------------- + + /// Newest first. + List get entries { + final list = _entries.values.toList() + ..sort((a, b) => b.firstSeen.compareTo(a.firstSeen)); + return List.unmodifiable(list); + } + + ReceivedImageEntry? entryFor(String streamId) => _entries[streamId]; + + ReceivedImageEntry? entryForKey(ImageStreamKey key) { + final id = _byKey[key]; + return id == null ? null : _entries[id]; + } + + /// Per-image listenable so one arriving chunk rebuilds one bubble instead of + /// the whole reversed `ListView`. + ValueListenable listenableFor(String streamId) { + return _listenables.putIfAbsent( + streamId, + () => ValueNotifier(_entries[streamId]), + ); + } + + /// Bytes currently held on disk by all entries. + int get totalBytes => + _entries.values.fold(0, (sum, e) => sum + e.storedBytes); + + int get storedImageCount => + _entries.values.where((e) => e.pngStored).length; + + /// Stream ids waiting for an automatic decode, oldest request first. + List get decodeQueue => List.unmodifiable(_queue); + + bool get isForeground => _foreground; + + /// Availability of the decoder seam, or `unavailable` when none is wired. + /// + /// Read-only passthrough so a bubble can pick its tap action (decode now vs. + /// send the user to the model download) without importing + /// `ImageCodecService`. + ImageCodecAvailability get decoderAvailability => + decoder?.availability ?? ImageCodecAvailability.unavailable; + + /// True while a decode is running or queued. Callers that want to grey out a + /// "Tap to process" affordance can use it; decodes are strictly serial, so a + /// second tap only lengthens the wall clock, never the peak RSS. + bool get isDecoding => _draining || _queue.isNotEmpty; + + // ---- startup ------------------------------------------------------------- + + /// Loads sidecars and repairs states that cannot survive a process death. + /// + /// receiving -> failedIncomplete (partials were never persisted) + /// decoding -> reassembled if the bitstream is there, else failedIncomplete + /// decoded -> evicted if the PNG is gone + Future load() async { + final sidecars = await blobs.readSidecars(); + for (final raw in sidecars.values) { + Map json; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) continue; + json = decoded; + } catch (_) { + continue; + } + var entry = ReceivedImageEntry.fromJson(json); + if (entry.streamId.isEmpty) continue; + + // Sizes, not bytes: reattaching 200 images must not read ~80 MB of PNG + // into RAM on the startup path. The pixels are read lazily by + // [ensurePng] when a bubble actually scrolls into view. + final bitstreamBytes = await blobs.bitstreamSize(entry.streamId); + final pngBytes = await blobs.pngSize(entry.streamId); + entry = entry.copyWith( + bitstreamStored: bitstreamBytes != null, + bitstreamByteCount: bitstreamBytes ?? 0, + pngStored: pngBytes != null, + pngByteCount: pngBytes ?? entry.pngByteCount, + ); + + switch (entry.state) { + case ReceivedImageState.receiving: + entry = entry.copyWith(state: ReceivedImageState.failedIncomplete); + case ReceivedImageState.decoding: + entry = entry.copyWith( + state: entry.bitstreamStored + ? ReceivedImageState.reassembled + : ReceivedImageState.failedIncomplete, + ); + case ReceivedImageState.decoded: + if (!entry.pngStored) { + entry = entry.copyWith(state: ReceivedImageState.evicted); + } + case ReceivedImageState.reassembled: + if (!entry.bitstreamStored) { + entry = entry.copyWith(state: ReceivedImageState.failedIncomplete); + } else if (!processAutomatically) { + // load() never enqueues, so a `reassembled` entry restored with + // needsManualDecode == false (written by a session that had the + // setting on, or by an older build) would sit on "Waiting to + // decode" forever with no tap target. Give it one. + entry = entry.copyWith(needsManualDecode: true); + } + case ReceivedImageState.evicted: + case ReceivedImageState.failedIncomplete: + case ReceivedImageState.failedCorrupt: + case ReceivedImageState.decoderUnavailable: + break; + } + + _entries[entry.streamId] = entry; + _byKey[entry.key] = entry.streamId; + _publish(entry); + await _persist(entry); + } + await evictToBudget(); + _notify(); + } + + // ---- intake -------------------------------------------------------------- + + /// Feeds one [ImageReassembler] outcome. + /// + /// The caller owns the reassembler (the send path already builds one) and is + /// responsible for having dropped nothing: `malformed` and `fromSelf` are + /// ignored here too, so an unfiltered firehose is safe. + Future handleOutcome( + ImageChunkOutcome outcome, { + required int channelIndex, + DateTime? at, + }) async { + final now = at ?? _clock(); + switch (outcome.status) { + case ImageChunkStatus.malformed: + case ImageChunkStatus.fromSelf: + return null; + + case ImageChunkStatus.duplicate: + final header = outcome.header; + if (header == null) return null; + return entryForKey( + ImageStreamKey( + senderPrefix: header.senderPrefix, + imgId: header.imgId, + channelIndex: channelIndex, + ), + ); + + case ImageChunkStatus.accepted: + case ImageChunkStatus.conflicting: + final header = outcome.header; + if (header == null) return null; + final key = ImageStreamKey( + senderPrefix: header.senderPrefix, + imgId: header.imgId, + channelIndex: channelIndex, + ); + final counts = header.isParity ? 0 : 1; + final existing = entryForKey(key); + + // `conflicting` means the reassembler threw the old stream away and + // restarted from this chunk. A stream that had already been surfaced + // keeps its id (and therefore its message) only if it was still + // receiving; anything else gets a fresh entry. + final resets = outcome.status == ImageChunkStatus.conflicting; + if (existing != null && + (!resets || existing.state == ReceivedImageState.receiving)) { + final updated = existing.copyWith( + state: ReceivedImageState.receiving, + receivedChunks: resets + ? counts + : (existing.receivedChunks + counts) + .clamp(0, header.total) + .toInt(), + totalChunks: header.total, + error: null, + ); + return _store(updated); + } + if (existing != null) { + _byKey.remove(key); + } + final streamId = _uniqueStreamId( + senderPrefix: header.senderPrefix, + imgId: header.imgId, + firstSeen: now, + ); + final created = ReceivedImageEntry( + streamId: streamId, + senderPrefix: header.senderPrefix, + imgId: header.imgId, + channelIndex: channelIndex, + firstSeen: now, + state: ReceivedImageState.receiving, + receivedChunks: counts, + totalChunks: header.total, + ); + return _store(created); + + case ImageChunkStatus.unsupportedFormat: + // Every chunk arrived but chunk 0 names a rate point or resolution this + // build cannot decode. The transport has already DISCARDED the bytes, + // and there is nothing to retry, so no bitstream is stored. + final header = outcome.header; + if (header == null) return null; + final entry = entryForKey( + ImageStreamKey( + senderPrefix: header.senderPrefix, + imgId: header.imgId, + channelIndex: channelIndex, + ), + ); + if (entry == null) return null; + _queue.remove(entry.streamId); + return _store( + entry.copyWith( + state: ReceivedImageState.failedCorrupt, + bitstreamStored: false, + error: 'Unsupported image format.', + ), + ); + + case ImageChunkStatus.completed: + final result = outcome.result; + if (result == null) return null; + return _handleCompleted(result, now); + } + } + + Future _handleCompleted( + ImageReassemblyResult result, + DateTime now, + ) async { + final key = result.key; + var entry = entryForKey(key); + if (entry == null) { + final streamId = _uniqueStreamId( + senderPrefix: key.senderPrefix, + imgId: key.imgId, + firstSeen: now, + ); + entry = ReceivedImageEntry( + streamId: streamId, + senderPrefix: key.senderPrefix, + imgId: key.imgId, + channelIndex: key.channelIndex, + firstSeen: now, + state: ReceivedImageState.receiving, + receivedChunks: result.chunkCount, + totalChunks: result.chunkCount, + ); + } + + final metadata = result.metadata; + // Bitstream first, state second: a kill between the two must be + // recoverable, and `reassembled` with no file on disk is a lie. + await blobs.writeBitstream(entry.streamId, result.data); + + final updated = entry.copyWith( + state: ReceivedImageState.reassembled, + receivedChunks: result.chunkCount, + totalChunks: result.chunkCount, + rate: metadata == null + ? AeicRatePoint.ft32 + : aeicRatePointForUi(metadata.rate), + aspectCode: metadata?.aspectCode ?? 0, + resolution: metadata?.squareSize ?? kImageCodecSquareSize, + metadataAssumed: metadata == null, + recoveredWithParity: result.recoveredWithParity, + bitstreamStored: true, + bitstreamByteCount: result.data.length, + error: null, + ); + if (!processAutomatically) { + // Park it: the card shows "N bytes · M packets" and "Tap to process". + // A 2.16 GiB decode is never started off the back of a radio packet + // unless the user asked for that. + return _store(updated.copyWith(needsManualDecode: true)); + } + final stored = await _store(updated); + _enqueue(stored.streamId); + return stored; + } + + /// A stream the reassembler gave up on (TTL or concurrency overflow). + Future handleFailure( + ImageReassemblyFailure failure, + ) async { + final entry = entryForKey(failure.key); + if (entry == null) return null; + if (entry.state != ReceivedImageState.receiving) { + // Already completed by a later chunk; the failure is stale. + return entry; + } + return _store( + entry.copyWith( + // `isCorrupt` means every chunk arrived but the bytes were unusable + // (CRC-16 mismatch or an undecodable metadata byte). The UI must say + // "corrupt", not "incomplete". + state: failure.isCorrupt + ? ReceivedImageState.failedCorrupt + : ReceivedImageState.failedIncomplete, + receivedChunks: failure.receivedDataChunks, + totalChunks: failure.total, + error: failure.isCorrupt ? failure.reason.name : null, + ), + ); + } + + /// Records the local user's own send so the outgoing bubble can show the real + /// 512x512 crop. [previewPng] is a photograph, not a decode, so the entry is + /// never [ReceivedImageEntry.synthesized] and carries no R6 label. + /// + /// The send path must call this BEFORE it posts the message, and put the + /// returned id in the message text — that sentinel is the only link between + /// the `ChannelMessage` and these pixels: + /// + /// ```dart + /// final entry = await receivedImageStore.registerOutgoing( + /// channelIndex: channelIndex, + /// senderPrefix: selfPrefix, // same 16 bits the chunk header carries + /// imgId: chunkSet.imgId, + /// previewPng: preview.croppedPngBytes, // the 512x512 crop, NOT a decode + /// rate: kImageSendRatePoint, + /// chunkCount: chunkSet.dataChunkCount, + /// ); + /// await connector.sendChannelMessage( + /// channelIndex, ReceivedImageRef.encode(entry.streamId)); + /// ``` + /// + /// Nothing is ever decoded for an outgoing entry: it lands in `decoded` + /// directly and never enters the queue, so sending an image costs no model + /// memory beyond the encode that already ran. + Future registerOutgoing({ + required int channelIndex, + required int senderPrefix, + required int imgId, + required Uint8List previewPng, + required AeicRatePoint rate, + required int chunkCount, + DateTime? at, + }) async { + final now = at ?? _clock(); + final streamId = _uniqueStreamId( + senderPrefix: senderPrefix, + imgId: imgId, + firstSeen: now, + ); + await blobs.writePng(streamId, previewPng); + final entry = ReceivedImageEntry( + streamId: streamId, + senderPrefix: senderPrefix, + imgId: imgId, + channelIndex: channelIndex, + firstSeen: now, + state: ReceivedImageState.decoded, + receivedChunks: chunkCount, + totalChunks: chunkCount, + rate: rate, + metadataAssumed: false, + isOutgoing: true, + pngStored: true, + pngByteCount: previewPng.length, + pngBytes: previewPng, + ); + final stored = await _store(entry); + await evictToBudget(protect: stored.streamId); + return stored; + } + + // ---- decode scheduling --------------------------------------------------- + + /// App lifecycle gate. Backgrounding cancels an in-flight decode and returns + /// the entry to `reassembled` — never to a failure state. + void setForeground(bool value) { + if (_foreground == value) return; + _foreground = value; + if (!value) { + decoder?.cancelCodecJob(); + } else { + _parkedOnBusyDecoder = false; + _kick(); + } + _notify(); + } + + /// Call when the codec's availability or busy flag changes: a queue parked on + /// a busy encoder resumes, and images that were parked as + /// `decoderUnavailable` get another go now that a decoder exists. + void notifyDecoderChanged() { + _parkedOnBusyDecoder = false; + final decoder = this.decoder; + if (decoder != null && + decoder.availability == ImageCodecAvailability.ready) { + for (final entry in _entries.values.toList()) { + if (entry.state != ReceivedImageState.decoderUnavailable) continue; + if (!entry.bitstreamStored) continue; + _storeSync( + entry.copyWith( + state: ReceivedImageState.reassembled, + needsManualDecode: !processAutomatically, + ), + ); + // Without this guard, finishing an 835 MB download would immediately + // fire one decode per image received while the model was missing — + // exactly what the setting exists to prevent. + if (processAutomatically) _queue.add(entry.streamId); + } + } + _kick(); + } + + /// Cancels the running decode and stops the queue; the entry goes back to + /// `reassembled` and will be picked up again later. + Future handleMemoryPressure() async { + decoder?.cancelCodecJob(); + _queue.clear(); + for (final entry in _entries.values.toList()) { + if (entry.state == ReceivedImageState.reassembled && + !entry.needsManualDecode) { + await _store(entry.copyWith(needsManualDecode: true)); + } + } + } + + /// User tapped "Decode" / "Try again" / "Decode again". + Future requestDecode(String streamId) async { + final entry = _entries[streamId]; + if (entry == null) return; + switch (entry.state) { + case ReceivedImageState.reassembled: + case ReceivedImageState.failedCorrupt: + case ReceivedImageState.decoderUnavailable: + case ReceivedImageState.evicted: + break; + case ReceivedImageState.receiving: + case ReceivedImageState.decoding: + case ReceivedImageState.decoded: + case ReceivedImageState.failedIncomplete: + return; + } + if (!entry.bitstreamStored) return; + await _store( + entry.copyWith( + state: ReceivedImageState.reassembled, + needsManualDecode: false, + error: null, + ), + ); + _parkedOnBusyDecoder = false; + _enqueue(streamId, force: true); + await settle(); + } + + /// Completes when the decode queue has stopped making progress. Test hook; + /// production code never needs to await it. + Future settle() async { + var guard = 0; + while (guard++ < 1000) { + final pump = _pump; + if (pump != null) { + await pump; + continue; + } + if (_disposed || + _queue.isEmpty || + _draining || + _parkedOnBusyDecoder || + !_foreground) { + return; + } + _kick(); + if (_pump == null) return; + } + } + + void _enqueue(String streamId, {bool force = false}) { + if (_queue.contains(streamId)) return; + // A new arrival is worth one more look at a codec that was busy. + _parkedOnBusyDecoder = false; + _queue.add(streamId); + if (!force) { + // Burst cap: keep the newest `burstCap` requests, park the rest behind a + // tap so a repeater storm cannot spend minutes of CPU. + while (_queue.length > burstCap) { + final dropped = _queue.removeAt(0); + final entry = _entries[dropped]; + if (entry != null && entry.state == ReceivedImageState.reassembled) { + _storeSync(entry.copyWith(needsManualDecode: true)); + } + } + } + _kick(); + } + + void _kick() { + if (_disposed) return; + if (_queue.isEmpty) return; + if (_draining) return; + if (_parkedOnBusyDecoder) return; + _draining = true; + _pump = _drain().whenComplete(() { + _pump = null; + }); + } + + Future _drain() async { + try { + while (_queue.isNotEmpty && _foreground && !_disposed) { + final decoder = this.decoder; + final streamId = _queue.first; + final entry = _entries[streamId]; + if (entry == null || entry.state != ReceivedImageState.reassembled) { + _queue.removeAt(0); + continue; + } + if (decoder == null || + decoder.availability != ImageCodecAvailability.ready) { + _queue.removeAt(0); + await _store( + entry.copyWith(state: ReceivedImageState.decoderUnavailable), + ); + continue; + } + if (decoder.isBusy) { + // The compose UI is encoding. Leave the queue intact; the next + // notifyDecoderChanged()/chunk resumes it. + _parkedOnBusyDecoder = true; + return; + } + _queue.removeAt(0); + await _decodeOne(entry, decoder); + } + } finally { + // Synchronous with the end of the body, unlike the future's completion. + _draining = false; + } + } + + /// Belt to [_draining]'s braces. A decode peaks around 2.16 GiB, so two at + /// once is not a slow app, it is an OOM kill. If this ever trips, the queue + /// invariant is broken somewhere and the right answer is still to run one. + bool _decodeInFlight = false; + + Future _decodeOne( + ReceivedImageEntry entry, + ReceivedImageDecoder decoder, + ) async { + if (_decodeInFlight) { + assert(false, 'ReceivedImageStore: concurrent decode attempted'); + debugPrint('received_image_store: refusing a concurrent decode'); + _queue.remove(entry.streamId); + return; + } + _decodeInFlight = true; + try { + await _decodeOneExclusive(entry, decoder); + } finally { + _decodeInFlight = false; + } + } + + Future _decodeOneExclusive( + ReceivedImageEntry entry, + ReceivedImageDecoder decoder, + ) async { + final bitstream = await blobs.readBitstream(entry.streamId); + if (bitstream == null || bitstream.isEmpty) { + await _store( + entry.copyWith( + state: ReceivedImageState.failedCorrupt, + bitstreamStored: false, + error: 'Bitstream missing.', + ), + ); + return; + } + final started = _clock(); + await _store(entry.copyWith(state: ReceivedImageState.decoding)); + + ImageCodecResult? result; + try { + result = await decoder.decodeBitstream( + bitstream: bitstream, + ratePoint: entry.rate, + resolution: entry.resolution, + ); + } catch (error) { + final current = _entries[entry.streamId] ?? entry; + if (!_foreground) { + await _store(current.copyWith(state: ReceivedImageState.reassembled)); + } else { + await _store( + current.copyWith( + state: ReceivedImageState.failedCorrupt, + error: error.toString(), + ), + ); + } + return; + } + + final current = _entries[entry.streamId] ?? entry; + if (result == null) { + // canDecode false — the codec cannot run at all. + await _store( + current.copyWith(state: ReceivedImageState.decoderUnavailable), + ); + return; + } + final png = result.pngBytes; + if (result.status != ImageCodecStatus.completed || png == null) { + await _store( + current.copyWith( + state: ReceivedImageState.failedCorrupt, + error: 'Decoder rejected the bitstream.', + ), + ); + return; + } + if (!_foreground) { + // Backgrounded mid-decode: keep the pixels, but do not claim a decode we + // may have half-cancelled. + await _store(current.copyWith(state: ReceivedImageState.reassembled)); + return; + } + + await blobs.writePng(current.streamId, png); + await _store( + current.copyWith( + state: ReceivedImageState.decoded, + pngStored: true, + pngByteCount: png.length, + pngBytes: png, + decodeMs: result.durationMs > 0 + ? result.durationMs + : _clock().difference(started).inMilliseconds, + needsManualDecode: false, + error: null, + ), + ); + // Without `protect` the freshly decoded image is the budget's own victim + // whenever it is also the oldest: decode -> evict -> "Decode again" -> + // evict, forever. + await evictToBudget(protect: current.streamId); + } + + // ---- pixels -------------------------------------------------------------- + + /// Reads (and caches) the decoded PNG for a `decoded` entry. Returns null for + /// every other state, so a failed or corrupt image can never present as + /// decoded pixels. + Future ensurePng(String streamId) async { + final entry = _entries[streamId]; + if (entry == null) return null; + if (entry.state != ReceivedImageState.decoded) return null; + final cached = entry.pngBytes; + if (cached != null) return cached; + if (!entry.pngStored) return null; + final bytes = await blobs.readPng(streamId); + if (bytes == null) { + await _store(entry.copyWith(state: ReceivedImageState.evicted, pngStored: false)); + return null; + } + await _store(entry.copyWith(pngBytes: bytes, pngByteCount: bytes.length)); + return bytes; + } + + /// Absolute PNG path, for a share/save action. Null on platforms with no file + /// system, and null unless the entry is decoded. + String? pngPath(String streamId) { + final entry = _entries[streamId]; + if (entry == null || entry.state != ReceivedImageState.decoded) return null; + return blobs.pngPath(streamId); + } + + // ---- deletion and eviction ---------------------------------------------- + + /// Removes an image and all three of its files. Called when the user deletes + /// the message (or clears the conversation), otherwise ~400 KB is orphaned + /// forever. + Future deleteImage(String streamId) async { + final entry = _entries.remove(streamId); + _queue.remove(streamId); + if (entry != null) { + _byKey.remove(entry.key); + } + await blobs.deletePng(streamId); + await blobs.deleteBitstream(streamId); + await blobs.deleteSidecar(streamId); + final listenable = _listenables[streamId]; + if (listenable != null) { + listenable.value = null; + } + _notify(); + } + + /// Deletes every image of one channel. Call this when the conversation is + /// cleared or the channel removed, otherwise each image leaks ~400 KB until + /// the 30-day age budget finally reaps it. + /// + /// Returns the stream ids that were removed, so the caller can strip their + /// sentinels from the channel's message list in one pass. + Future> deleteImagesForChannel(int channelIndex) async { + final doomed = _entries.values + .where((e) => e.channelIndex == channelIndex) + .map((e) => e.streamId) + .toList(); + for (final streamId in doomed) { + await deleteImage(streamId); + } + return doomed; + } + + /// Deletes an image for each sentinel that is still present in a channel's + /// message list, and drops everything else this store holds for that channel. + /// + /// Convenience for the single-message delete path: the caller has a + /// `ChannelMessage.text`, not a stream id. + Future deleteImageForSentinel(String text) async { + final streamId = ReceivedImageRef.parse(text); + if (streamId == null) return; + await deleteImage(streamId); + } + + /// Brings the store back inside [maxImages] / [maxBytes] / [maxAge]. + /// + /// Order matters: PNGs (~400 KB) go before bitstreams (~156 B), and oldest + /// `firstSeen` first. An evicted entry keeps its message and its + /// `evicted` state so the bubble can offer "Decode again". + Future> evictToBudget({String? protect}) async { + final evicted = []; + final now = _clock(); + + // 1. Age budget: nothing survives, not even the bitstream. + for (final entry in _entries.values.toList()) { + if (now.difference(entry.firstSeen) <= maxAge) continue; + if (!entry.pngStored && !entry.bitstreamStored) continue; + await blobs.deletePng(entry.streamId); + await blobs.deleteBitstream(entry.streamId); + await _store( + entry.copyWith( + state: ReceivedImageState.evicted, + pngStored: false, + bitstreamStored: false, + pngBytes: null, + ), + ); + _queue.remove(entry.streamId); + evicted.add(entry.streamId); + } + + // 2. Size/count budget. + var guard = 0; + while ((storedImageCount > maxImages || totalBytes > maxBytes) && + guard++ < 10000) { + final pngHolders = + _entries.values + .where((e) => e.pngStored && e.streamId != protect) + .toList() + ..sort((a, b) => a.firstSeen.compareTo(b.firstSeen)); + if (pngHolders.isNotEmpty) { + final victim = pngHolders.first; + await blobs.deletePng(victim.streamId); + await _store( + victim.copyWith( + state: victim.state == ReceivedImageState.decoded + ? ReceivedImageState.evicted + : victim.state, + pngStored: false, + pngBytes: null, + ), + ); + evicted.add(victim.streamId); + continue; + } + // Only bitstreams of already-evicted entries may go; a `reassembled` + // entry still needs its bytes to decode. + final bitstreamHolders = + _entries.values + .where( + (e) => + e.bitstreamStored && + (e.state == ReceivedImageState.evicted || + e.state == ReceivedImageState.failedCorrupt), + ) + .toList() + ..sort((a, b) => a.firstSeen.compareTo(b.firstSeen)); + if (bitstreamHolders.isEmpty) break; + final victim = bitstreamHolders.first; + await blobs.deleteBitstream(victim.streamId); + await _store( + victim.copyWith( + state: ReceivedImageState.evicted, + bitstreamStored: false, + ), + ); + evicted.add(victim.streamId); + } + + if (evicted.isNotEmpty) _notify(); + return evicted; + } + + // ---- plumbing ------------------------------------------------------------ + + String _uniqueStreamId({ + required int senderPrefix, + required int imgId, + required DateTime firstSeen, + }) { + // Second-resolution ids collide when the same sender reuses an img_id + // inside one second (id wrap, or a restart re-rolling the allocator seed). + var at = firstSeen; + var id = ReceivedImageRef.streamIdFor( + senderPrefix: senderPrefix, + imgId: imgId, + firstSeen: at, + ); + var bump = 0; + while (_entries.containsKey(id) && bump++ < 64) { + at = at.add(const Duration(seconds: 1)); + id = ReceivedImageRef.streamIdFor( + senderPrefix: senderPrefix, + imgId: imgId, + firstSeen: at, + ); + } + return id; + } + + Future _store(ReceivedImageEntry entry) async { + _storeSync(entry); + await _persist(entry); + return entry; + } + + void _storeSync(ReceivedImageEntry entry) { + _entries[entry.streamId] = entry; + _byKey[entry.key] = entry.streamId; + _publish(entry); + _notify(); + } + + Future _persist(ReceivedImageEntry entry) async { + try { + await blobs.writeSidecar(entry.streamId, jsonEncode(entry.toJson())); + } catch (error) { + debugPrint('received_image_store: sidecar write failed: $error'); + } + } + + void _publish(ReceivedImageEntry entry) { + final listenable = _listenables[entry.streamId]; + if (listenable != null) { + listenable.value = entry; + } + } + + void _notify() { + if (_disposed) return; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _queue.clear(); + for (final listenable in _listenables.values) { + listenable.dispose(); + } + _listenables.clear(); + super.dispose(); + } +} diff --git a/lib/utils/lora_airtime.dart b/lib/utils/lora_airtime.dart new file mode 100644 index 00000000..0c65bacc --- /dev/null +++ b/lib/utils/lora_airtime.dart @@ -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 imageChunkPayloadSizes(int payloadBytes) { + final count = imageChunkCount(payloadBytes); + if (count == 0) return const []; + final sizes = []; + 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 = []; + 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(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, + ); +} diff --git a/lib/widgets/image_send_button.dart b/lib/widgets/image_send_button.dart new file mode 100644 index 00000000..f1482a5c --- /dev/null +++ b/lib/widgets/image_send_button.dart @@ -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, + ); + } +} diff --git a/lib/widgets/image_send_codec_binding.dart b/lib/widgets/image_send_codec_binding.dart new file mode 100644 index 00000000..02eaffdc --- /dev/null +++ b/lib/widgets/image_send_codec_binding.dart @@ -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 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 encode( + Uint8List imageBytes, + ImageCodecRatePoint rate, + ) async { + if (latency > Duration.zero) { + await Future.delayed(latency); + } + final size = ImageCodecRateStats.forRate(rate).meanBytes; + return Uint8List.fromList( + List.generate(size, (i) => (i * 31 + rate.index) & 0xFF), + ); + } +} diff --git a/lib/widgets/image_send_preview_sheet.dart b/lib/widgets/image_send_preview_sheet.dart new file mode 100644 index 00000000..db5a8324 --- /dev/null +++ b/lib/widgets/image_send_preview_sheet.dart @@ -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 showImageSendPreviewSheet({ + required BuildContext context, + required Uint8List imageBytes, + required int originalFileBytes, + required ImageSendCodec codec, + ImageSendRadio? radio, + bool initialParity = true, +}) { + return showModalBottomSheet( + 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 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 { + /// 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 _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(); + 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 _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 _warnings( + ThemeData theme, + ColorScheme colors, + ImageSendRadio radio, + _RateEstimate estimate, + ) { + final localizations = context.l10n; + final widgets = []; + 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'; + } +} diff --git a/lib/widgets/received_image_message.dart b/lib/widgets/received_image_message.dart new file mode 100644 index 00000000..2c0188e4 --- /dev/null +++ b/lib/widgets/received_image_message.dart @@ -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 createState() => _ReceivedImageMessageState(); +} + +class _ReceivedImageMessageState extends State { + 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(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( + 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( + 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), + ), + ], + ), + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..e30f8886 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,17 @@ #include "generated_plugin_registrant.h" +#include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_onnxruntime_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterOnnxruntimePlugin"); + flutter_onnxruntime_plugin_register_with_registrar(flutter_onnxruntime_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93e46829..9c7e2a7a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_onnxruntime url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3ba5e9d1..9f7f4ab8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,8 +6,10 @@ import FlutterMacOS import Foundation import cryptography_flutter +import file_selector_macos import flutter_blue_plus_darwin import flutter_local_notifications +import flutter_onnxruntime import mobile_scanner import package_info_plus import share_plus @@ -17,8 +19,10 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterOnnxruntimePlugin.register(with: registry.registrar(forPlugin: "FlutterOnnxruntimePlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/macos/Podfile b/macos/Podfile index ff5ddb3b..0d122438 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 0f1561a5..04f338b5 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -17,6 +17,6 @@ SPEC CHECKSUMS: flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 +PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 10f8906b..ea27169d 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -567,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -649,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -699,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..cf855ad1 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "onnxruntime-swift-package-manager", + "kind" : "remoteSourceControl", + "location" : "https://github.com/masicai/onnxruntime-swift-package-manager", + "state" : { + "revision" : "57f28b1fdd6fe33585370b146a46c597d6750953", + "version" : "1.23.1" + } + } + ], + "version" : 2 +} diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..cf855ad1 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "onnxruntime-swift-package-manager", + "kind" : "remoteSourceControl", + "location" : "https://github.com/masicai/onnxruntime-swift-package-manager", + "state" : { + "revision" : "57f28b1fdd6fe33585370b146a46c597d6750953", + "version" : "1.23.1" + } + } + ], + "version" : 2 +} diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 2fcad1b4..103ed4dc 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -22,5 +22,8 @@ com.apple.security.device.camera + + com.apple.security.files.user-selected.read-only + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index c55e9d73..9560762d 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -32,5 +32,7 @@ MeshCore needs Bluetooth to communicate with LoRa mesh devices NSCameraUsageDescription This app uses the camera to scan QR codes for joining communities. + NSPhotoLibraryUsageDescription + This app lets you pick a photo to compress and send over the mesh. diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 2b1c6948..c97754d5 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -18,5 +18,8 @@ com.apple.security.device.camera + + com.apple.security.files.user-selected.read-only + diff --git a/pubspec.yaml b/pubspec.yaml index eaa5e5ac..2f6426e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: characters: ^1.4.0 package_info_plus: ^10.1.0 mobile_scanner: ^7.1.4 # QR/barcode scanning + image_picker: ^1.1.2 # Pick photos to send as compressed mesh images qr_flutter: ^4.1.0 # QR code generation url_launcher: ^6.3.0 # Launch URLs in system browser flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text @@ -76,6 +77,11 @@ dependencies: cryptography: ^2.9.0 cryptography_flutter: ^2.3.4 convert: ^3.1.2 + # ONNX Runtime 1.23.0 via platform channels. Powers the AEIC-SE image codec + # decoder (lib/services/image_codec_backend.dart). arm64-only on Android — see + # the abiFilters note in android/app/build.gradle.kts; a universal APK pays + # ~56 MB for ORT instead of ~18 MB. + flutter_onnxruntime: ^1.8.3 hooks: user_defines: diff --git a/test/image_chunk_transport_test.dart b/test/image_chunk_transport_test.dart new file mode 100644 index 00000000..a52d0b97 --- /dev/null +++ b/test/image_chunk_transport_test.dart @@ -0,0 +1,1171 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/services/image_chunk_transport.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; + +Uint8List payloadOf(int length, {int seed = 7}) => Uint8List.fromList( + List.generate(length, (i) => (i * 37 + seed * 11) & 0xFF), + ); + +const ImageStreamMetadata stdMeta = ImageStreamMetadata( + rate: ImageCodecRatePoint.standard, +); +const ImageStreamMetadata highMeta = ImageStreamMetadata( + rate: ImageCodecRatePoint.high, +); + +const int senderA = 0x1234; +const int senderB = 0xBEEF; + +/// A copy of [blob] with the byte at [offset] flipped by [mask]. +Uint8List flipByte(Uint8List blob, int offset, {int mask = 0x01}) { + final copy = Uint8List.fromList(blob); + copy[offset] ^= mask; + return copy; +} + +/// Feeds [blobs] to [r] in the given order and returns the completed image, if +/// any completed. +ImageReassemblyResult? feed( + ImageReassembler r, + List blobs, { + int channelIndex = 0, + DateTime? now, +}) { + ImageReassemblyResult? result; + for (final blob in blobs) { + final outcome = r.addChunk(blob, channelIndex: channelIndex, now: now); + result ??= outcome.result; + } + return result; +} + +void main() { + group('constants', () { + test('every blob fits the binding transport limit', () { + expect(kImageChunkBlobBytes, 163); + expect(kImageChunkHeaderBytes, 4); + expect(kImageChunkBodyBytes, 158); + // Chunk 0 spends 1 byte on metadata and nothing else: the CRC-16 that + // briefly lived here was removed once it turned out the LoRa PHY CRC and + // MeshCore's per-packet HMAC already cover a delivered chunk. + expect(kImageChunkMetadataBytes, 1); + expect(kImageChunkZeroMetadataBytes, 1); + expect(kImageChunkFirstCapacity, 157); + expect(kImageChunkCapacity, 158); + expect(kImageMaxPayloadBytes, 157 + 14 * 158); + }); + + test('measured codec worst cases hit the design chunk counts', () { + // ft32 (standard) max 209 B, ft16 (high) max 409 B. + expect(imageDataChunkCount(209), 2); + expect(imageDataChunkCount(409), 3); + // With chunk 0 back at 157 data bytes the measured ft32 MEAN fits in a + // single data chunk (2 packets with parity), which is the whole point of + // dropping the CRC. + expect(ImageCodecRateStats.standard.meanBytes, 156); + expect(imageDataChunkCount(ImageCodecRateStats.standard.meanBytes), 1); + expect(imageDataChunkCount(157), 1); + expect(imageDataChunkCount(158), 2); + expect(imageDataChunkCount(ImageCodecRateStats.standard.minBytes), 1); + expect(imageDataChunkCount(ImageCodecRateStats.high.meanBytes), 2); + }); + }); + + group('metadata byte', () { + test('round-trips both rate points', () { + for (final rate in ImageCodecRatePoint.values) { + final meta = ImageStreamMetadata(rate: rate); + expect(ImageStreamMetadata.decode(meta.encode()), meta); + } + }); + + test('rejects an unrepresentable resolution', () { + expect( + () => const ImageStreamMetadata( + rate: ImageCodecRatePoint.standard, + squareSize: 999, + ).encode(), + throwsArgumentError, + ); + }); + + test('an unknown RATE code is what signals a format break', () { + // The byte is aspect(4) | resolution(2) | rate(2). Resolution is now a + // closed 2-bit field: all four codes are legal, so an unknown resolution + // is no longer expressible. Rate keeps two spare codes (2 and 3), and + // that is the channel a future incompatible format must use so older + // receivers reject it instead of guessing. + expect(ImageStreamMetadata.decode(0x02), isNull); // rate code 2 + expect(ImageStreamMetadata.decode(0x03), isNull); // rate code 3 + // 0xF0 is aspect 15 (unknown shape), resolution 0, rate 0 — all legal. + final legal = ImageStreamMetadata.decode(0xF0); + expect(legal, isNotNull); + expect(legal!.aspectCode, kImageAspectUnknown); + expect(legal.isSquare, isTrue); + }); + }); + + group('chunking', () { + test('one, two and three chunk payloads produce the right blob shapes', () { + final cases = { + 1: 1, + kImageChunkFirstCapacity: 1, + kImageChunkFirstCapacity + 1: 2, + kImageChunkFirstCapacity + kImageChunkCapacity: 2, + kImageChunkFirstCapacity + kImageChunkCapacity + 1: 3, + }; + cases.forEach((length, expectedChunks) { + final set = buildImageChunks( + payload: payloadOf(length), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 3, + ); + expect(set.dataChunkCount, expectedChunks, reason: 'len $length'); + expect(set.blobs.length, expectedChunks + 1); // + parity + for (final blob in set.blobs) { + expect(blob.length, lessThanOrEqualTo(kImageChunkBlobBytes)); + } + }); + }); + + test('header fields are on the wire where the spec says', () { + final set = buildImageChunks( + payload: payloadOf(400), + metadata: highMeta, + senderPrefix: senderA, + imgId: 0x5A, + ); + expect(set.dataChunkCount, 3); + for (var i = 0; i < set.blobs.length; i++) { + final blob = set.blobs[i]; + expect(blob[0], 0x12); + expect(blob[1], 0x34); + expect(blob[2], 0x5A); + expect((blob[3] >> 4) & 0x0F, i); + expect(blob[3] & 0x0F, 3); + } + expect(parseImageChunkHeader(set.blobs.last)!.isParity, isTrue); + expect(set.blobs[4 - 1].length, greaterThan(kImageChunkHeaderBytes)); + // Chunk 0 carries the metadata byte first. + expect(set.blobs[0][kImageChunkHeaderBytes], highMeta.encode()); + }); + + test('rejects a payload larger than the framing can address', () { + expect( + () => buildImageChunks( + payload: payloadOf(kImageMaxPayloadBytes + 1), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 1, + ), + throwsArgumentError, + ); + }); + }); + + group('round trip', () { + for (final length in [ + 100, // 1 chunk + 300, // 2 chunks + 460, // 3 chunks + ]) { + test('$length bytes round-trips in order', () { + final payload = payloadOf(length); + final set = buildImageChunks( + payload: payload, + metadata: highMeta, + senderPrefix: senderA, + imgId: 11, + ); + final r = ImageReassembler(selfPrefix: senderB); + final result = feed(r, set.blobs); + expect(result, isNotNull); + expect(result!.data, payload); + expect(result.metadata, highMeta); + expect(result.recoveredWithParity, isFalse); + expect(result.chunkCount, set.dataChunkCount); + expect(r.pendingCount, 0, reason: 'trailing parity must not linger'); + }); + } + + test('completion callback fires exactly once', () { + final payload = payloadOf(300); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 4, + ); + final results = []; + final r = ImageReassembler(onImage: results.add); + feed(r, set.blobs); + expect(results.length, 1); + expect(results.single.data, payload); + }); + + test('out-of-order arrival still reassembles', () { + final payload = payloadOf(460, seed: 3); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 9, + ); + // All three data chunks arrive out of order, parity last (so this + // exercises reordering, not parity recovery). + final shuffled = [ + set.blobs[2], + set.blobs[0], + set.blobs[1], + set.blobs[3], // parity + ]; + final r = ImageReassembler(); + final result = feed(r, shuffled); + expect(result, isNotNull); + expect(result!.data, payload); + expect(result.recoveredWithParity, isFalse); + }); + + test('duplicate chunks are ignored', () { + final payload = payloadOf(460); // 3 data chunks + parity + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 2, + ); + final r = ImageReassembler(); + expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.accepted); + expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.duplicate); + expect(r.addChunk(set.blobs[2]).status, ImageChunkStatus.accepted); + expect(r.addChunk(set.blobs[2]).status, ImageChunkStatus.duplicate); + final done = r.addChunk(set.blobs[1]); + expect(done.status, ImageChunkStatus.completed); + expect(done.result!.data, payload); + expect(done.result!.recoveredWithParity, isFalse); + // Trailing parity and late duplicates are ignored, not restarted. + expect(r.addChunk(set.blobs[3]).status, ImageChunkStatus.duplicate); + expect(r.addChunk(set.blobs[1]).status, ImageChunkStatus.duplicate); + expect(r.pendingCount, 0); + }); + + test('a duplicate parity chunk is ignored', () { + final set = buildImageChunks( + payload: payloadOf(460), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 15, + ); + final r = ImageReassembler(); + expect(r.addChunk(set.blobs[3]).status, ImageChunkStatus.accepted); + expect(r.addChunk(set.blobs[3]).status, ImageChunkStatus.duplicate); + expect(r.pendingCount, 1); + }); + + test('parity completes an image that is one chunk short', () { + final payload = payloadOf(300); // 2 data chunks + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 16, + ); + final r = ImageReassembler(); + expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.accepted); + final done = r.addChunk(set.blobs[2]); // parity rebuilds chunk 1 + expect(done.status, ImageChunkStatus.completed); + expect(done.result!.data, payload); + expect(done.result!.recoveredWithParity, isTrue); + }); + + test('two senders interleaved on one channel do not mix', () { + final a = payloadOf(300, seed: 1); + final b = payloadOf(300, seed: 2); + final setA = buildImageChunks( + payload: a, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 5, + ); + final setB = buildImageChunks( + payload: b, + metadata: highMeta, + senderPrefix: senderB, + imgId: 5, // same img_id on purpose + ); + final got = {}; + final r = ImageReassembler( + onImage: (res) => got[res.key.senderPrefix] = res.data, + ); + feed(r, [ + setA.blobs[0], + setB.blobs[1], + setA.blobs[1], + setB.blobs[0], + ]); + expect(got[senderA], a); + expect(got[senderB], b); + }); + + test('the same img_id on different channels stays separate', () { + final payload = payloadOf(300); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 8, + ); + final r = ImageReassembler(); + expect( + r.addChunk(set.blobs[0], channelIndex: 0).status, + ImageChunkStatus.accepted, + ); + expect( + r.addChunk(set.blobs[1], channelIndex: 1).status, + ImageChunkStatus.accepted, + ); + expect(r.pendingCount, 2); + }); + }); + + group('parity recovery', () { + test('recovers any single lost data chunk (2 and 3 chunk images)', () { + for (final length in [300, 460]) { + final payload = payloadOf(length, seed: length); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 6, + ); + for (var lost = 0; lost < set.dataChunkCount; lost++) { + final blobs = [ + for (var i = 0; i < set.blobs.length; i++) + if (i != lost) set.blobs[i], + ]; + final r = ImageReassembler(); + final result = feed(r, blobs); + expect(result, isNotNull, reason: 'len $length lost $lost'); + expect(result!.data, payload, reason: 'len $length lost $lost'); + expect(result.metadata, stdMeta); + expect(result.recoveredWithParity, isTrue); + } + } + }); + + test('recovers the short final chunk (length is carried by parity)', () { + // 158 + 1 => final chunk is a single byte long. + final payload = payloadOf(kImageChunkFirstCapacity + 1); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 12, + ); + expect(set.dataChunkCount, 2); + final r = ImageReassembler(); + final result = feed(r, [set.blobs[0], set.blobs[2]]); + expect(result, isNotNull); + expect(result!.data, payload); + expect(result.recoveredWithParity, isTrue); + }); + + test('total=1 with parity recovers the only data chunk', () { + final payload = payloadOf(100); + final set = buildImageChunks( + payload: payload, + metadata: highMeta, + senderPrefix: senderA, + imgId: 1, + ); + expect(set.dataChunkCount, 1); + expect(set.blobs.length, 2); + final r = ImageReassembler(); + final result = feed(r, [set.blobs[1]]); // parity only + expect(result, isNotNull); + expect(result!.data, payload); + expect(result.metadata, highMeta); + expect(result.recoveredWithParity, isTrue); + }); + + test('losing two chunks fails and never yields a wrong image', () { + final payload = payloadOf(460); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 7, + ); + final r = ImageReassembler(); + // Deliver only chunk 1 and parity: two data chunks are missing. + final result = feed(r, [set.blobs[1], set.blobs[3]]); + expect(result, isNull); + expect(r.pendingCount, 1); + }); + + test('parity is optional', () { + final payload = payloadOf(300); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 3, + parity: false, + ); + expect(set.hasParity, isFalse); + expect(set.blobs.length, 2); + final r = ImageReassembler(); + expect(feed(r, set.blobs)!.data, payload); + }); + }); + + group('TTL and eviction', () { + test('a stalled image expires and is reported as failed', () { + final set = buildImageChunks( + payload: payloadOf(460), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 21, + ); + final failures = []; + final start = DateTime(2026, 1, 1, 12); + final r = ImageReassembler( + ttl: const Duration(seconds: 60), + onFailed: failures.add, + ); + r.addChunk(set.blobs[0], now: start); + r.addChunk(set.blobs[1], now: start.add(const Duration(seconds: 30))); + expect(r.pendingCount, 1); + + expect( + r.evictExpired(now: start.add(const Duration(seconds: 59))), + isEmpty, + ); + expect(r.pendingCount, 1); + + final expired = r.evictExpired(now: start.add(const Duration(minutes: 2))); + expect(expired.length, 1); + expect(expired.single.total, 3); + expect(expired.single.receivedDataChunks, 2); + expect(expired.single.hadParity, isFalse); + expect(expired.single.missingChunks, 1); + expect(failures.length, 1); + expect(r.pendingCount, 0); + }); + + test('addChunk sweeps expired streams', () { + final setOld = buildImageChunks( + payload: payloadOf(460), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 30, + ); + final setNew = buildImageChunks( + payload: payloadOf(300), + metadata: stdMeta, + senderPrefix: senderB, + imgId: 31, + ); + final failures = []; + final start = DateTime(2026, 1, 1); + final r = ImageReassembler(onFailed: failures.add); + r.addChunk(setOld.blobs[0], now: start); + r.addChunk(setNew.blobs[0], now: start.add(const Duration(minutes: 5))); + expect(failures.length, 1); + expect(failures.single.key.imgId, 30); + expect(r.pendingCount, 1); + }); + + test('an unrelated chunk after expiry does not resurrect the old data', () { + final set = buildImageChunks( + payload: payloadOf(300), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 40, + ); + final start = DateTime(2026, 5, 5); + final r = ImageReassembler(); + r.addChunk(set.blobs[0], now: start); + final late = r.addChunk( + set.blobs[1], + now: start.add(const Duration(minutes: 2)), + ); + // Chunk 0 expired, so chunk 1 alone cannot complete anything. + expect(late.status, ImageChunkStatus.accepted); + expect(late.result, isNull); + }); + + test('too many concurrent streams evicts the oldest', () { + final start = DateTime(2026, 2, 2); + final failures = []; + final r = ImageReassembler( + maxConcurrentStreams: 2, + onFailed: failures.add, + ); + for (var i = 0; i < 3; i++) { + final set = buildImageChunks( + payload: payloadOf(300), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 50 + i, + ); + r.addChunk(set.blobs[0], now: start.add(Duration(seconds: i))); + } + expect(r.pendingCount, 2); + expect(failures.length, 1); + expect(failures.single.key.imgId, 50); + }); + }); + + group('boundary cases', () { + test('empty payload round-trips as one chunk', () { + final set = buildImageChunks( + payload: Uint8List(0), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 0, + ); + expect(set.dataChunkCount, 1); + expect( + set.blobs[0].length, + kImageChunkHeaderBytes + kImageChunkZeroMetadataBytes, + ); + final r = ImageReassembler(); + final result = feed(r, set.blobs); + expect(result, isNotNull); + expect(result!.data, isEmpty); + expect(result.metadata, stdMeta); + }); + + test('empty payload recovers from parity alone', () { + final set = buildImageChunks( + payload: Uint8List(0), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 0, + ); + final r = ImageReassembler(); + final result = feed(r, [set.blobs[1]]); + expect(result, isNotNull); + expect(result!.data, isEmpty); + }); + + test('maximum addressable payload round-trips', () { + final payload = payloadOf(kImageMaxPayloadBytes); + final set = buildImageChunks( + payload: payload, + metadata: highMeta, + senderPrefix: senderA, + imgId: 255, + ); + expect(set.dataChunkCount, kImageMaxDataChunks); + expect(set.blobs.length, kImageMaxDataChunks + 1); + for (final blob in set.blobs) { + expect(blob.length, lessThanOrEqualTo(kImageChunkBlobBytes)); + } + final r = ImageReassembler(); + expect(feed(r, set.blobs.reversed.toList())!.data, payload); + }); + + test('maximum payload still recovers a single loss', () { + final payload = payloadOf(kImageMaxPayloadBytes, seed: 5); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 254, + ); + final blobs = [ + for (var i = 0; i < set.blobs.length; i++) + if (i != kImageMaxDataChunks - 1) set.blobs[i], + ]; + final r = ImageReassembler(); + final result = feed(r, blobs); + expect(result!.data, payload); + expect(result.recoveredWithParity, isTrue); + }); + + test('img_id wraps around 255 -> 0', () { + final alloc = ImageIdAllocator(seed: 254); + expect(alloc.next(), 254); + expect(alloc.next(), 255); + expect(alloc.next(), 0); + expect(alloc.next(), 1); + }); + + test('img_id wraparound reusing a key restarts the stream', () { + final first = buildImageChunks( + payload: payloadOf(460, seed: 1), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 77, + ); + final second = payloadOf(300, seed: 2); + final secondSet = buildImageChunks( + payload: second, + metadata: highMeta, + senderPrefix: senderA, + imgId: 77, // wrapped back onto the same id + ); + final r = ImageReassembler(); + // Partially deliver the first image (3 data chunks), then the second + // image (2 data chunks) arrives under the same key. + expect(r.addChunk(first.blobs[0]).status, ImageChunkStatus.accepted); + expect( + r.addChunk(secondSet.blobs[0]).status, + ImageChunkStatus.conflicting, + ); + final done = r.addChunk(secondSet.blobs[1]); + expect(done.status, ImageChunkStatus.completed); + expect(done.result!.data, second); + }); + + test('chunks bearing our own prefix are dropped as loopback', () { + final set = buildImageChunks( + payload: payloadOf(300), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 1, + ); + final r = ImageReassembler(selfPrefix: senderA); + expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.fromSelf); + expect(r.pendingCount, 0); + }); + + test('malformed blobs are rejected, not stored', () { + final r = ImageReassembler(); + expect( + r.addChunk(Uint8List.fromList([1, 2, 3])).status, + ImageChunkStatus.malformed, + ); + // total == 0 is illegal. + expect( + r.addChunk(Uint8List.fromList([0, 1, 2, 0x00, 9])).status, + ImageChunkStatus.malformed, + ); + // index > total is illegal. + expect( + r.addChunk(Uint8List.fromList([0, 1, 2, 0x32, 9])).status, + ImageChunkStatus.malformed, + ); + // Over-long blob cannot have come from this framing. + expect( + r.addChunk(Uint8List(kImageChunkBlobBytes + 1)).status, + ImageChunkStatus.malformed, + ); + expect(r.pendingCount, 0); + }); + + test('senderPrefixFromKey needs two bytes', () { + expect(senderPrefixFromKey(null), isNull); + expect(senderPrefixFromKey([0x12]), isNull); + expect(senderPrefixFromKey([0x12, 0x34, 0x56]), 0x1234); + }); + }); + + group('protocol glue', () { + test('CMD_SEND_CHANNEL_DATA flood frame matches the wire spec', () { + final frame = buildSendChannelDataFrame( + channelIndex: 0, + dataType: dataTypeAeicImage, + payload: Uint8List.fromList([0xAA, 0xBB]), + ); + expect(frame, [0x3E, 0x00, 0xFF, 0x1C, 0xAE, 0xAA, 0xBB]); + }); + + test('a direct-path frame inserts the path bytes before the data type', () { + final frame = buildSendChannelDataFrame( + channelIndex: 2, + dataType: dataTypeAeicImage, + payload: Uint8List.fromList([0x01]), + pathLen: 0x02, + path: Uint8List.fromList([0x11, 0x22]), + ); + expect(frame, [0x3E, 0x02, 0x02, 0x11, 0x22, 0x1C, 0xAE, 0x01]); + }); + + test('RESP_CODE_CHANNEL_DATA_RECV parses, snr is signed', () { + final frame = Uint8List.fromList([ + 0x1B, 0xF8, 0, 0, 3, 0xFF, 0x1C, 0xAE, 2, 0x42, 0x43, + ]); + final parsed = parseChannelDataFrame(frame)!; + expect(parsed.snrRaw, -8); + expect(parsed.snrDb, -2.0); + expect(parsed.channelIndex, 3); + expect(parsed.arrivedByFlood, isFalse); + expect(parsed.hopCount, isNull); + expect(parsed.dataType, dataTypeAeicImage); + expect(parsed.payload, [0x42, 0x43]); + }); + + test('a flooded frame exposes hop count and hash width', () { + final frame = Uint8List.fromList([ + 0x1B, 0x04, 0, 0, 0, 0x43, 0x1C, 0xAE, 0, + ]); + final parsed = parseChannelDataFrame(frame)!; + expect(parsed.arrivedByFlood, isTrue); + expect(parsed.hopCount, 3); + expect(parsed.pathHashWidth, 2); + expect(parsed.payload, isEmpty); + }); + + test('truncated or foreign frames parse as null', () { + expect(parseChannelDataFrame(Uint8List(4)), isNull); + expect( + parseChannelDataFrame( + Uint8List.fromList([0x1B, 0, 0, 0, 0, 0xFF, 0x1C, 0xAE, 5, 1]), + ), + isNull, + ); + expect( + parseChannelDataFrame( + Uint8List.fromList([0x10, 0, 0, 0, 0, 0xFF, 0x1C, 0xAE, 0]), + ), + isNull, + ); + }); + + test('transport sends chunks strictly sequentially and reassembles', () async { + final sent = []; + var inFlight = 0; + var maxInFlight = 0; + final received = []; + final rxWithCallback = ImageReassembler( + selfPrefix: senderB, + onImage: received.add, + ); + + final tx = ImageChunkTransport( + senderPrefix: senderA, + reassembler: rxWithCallback, + idAllocator: ImageIdAllocator(seed: 60), + send: (blob, channelIndex) async { + inFlight++; + maxInFlight = maxInFlight > inFlight ? maxInFlight : inFlight; + await Future.delayed(const Duration(milliseconds: 1)); + sent.add(blob); + inFlight--; + }, + ); + + final payload = payloadOf(460, seed: 9); + final set = await tx.sendImage( + payload: payload, + metadata: highMeta, + channelIndex: 1, + ); + expect(set.imgId, 60); + expect(sent.length, 4); + expect(maxInFlight, 1); + + // Loop the blobs back through the receive path as real frames. + for (final blob in sent) { + final frame = BytesBuilder() + ..add([0x1B, 0x10, 0, 0, 1, 0xFF, 0x1C, 0xAE, blob.length]) + ..add(blob); + tx.handleFrame(frame.toBytes()); + } + expect(received.length, 1); + expect(received.single.data, payload); + expect(received.single.key.channelIndex, 1); + }); + + test('handleFrame ignores other data types and other frames', () { + final rx = ImageReassembler(); + final tx = ImageChunkTransport( + senderPrefix: senderA, + reassembler: rx, + send: (_, _) async {}, + ); + expect( + tx.handleFrame( + Uint8List.fromList([0x1B, 0, 0, 0, 0, 0xFF, 0x01, 0x00, 0]), + ), + isNull, + ); + expect(tx.handleFrame(Uint8List.fromList([0x00])), isNull); + expect(rx.pendingCount, 0); + }); + + test('concurrent sendImage calls do not interleave on the wire', () async { + final order = []; + final tx = ImageChunkTransport( + senderPrefix: senderA, + reassembler: ImageReassembler(), + idAllocator: ImageIdAllocator(seed: 100), + send: (blob, _) async { + await Future.delayed(const Duration(milliseconds: 1)); + order.add('${blob[2]}:${(blob[3] >> 4) & 0x0F}'); + }, + ); + final a = tx.sendImage(payload: payloadOf(300), metadata: stdMeta); + final b = tx.sendImage(payload: payloadOf(300), metadata: stdMeta); + await Future.wait(>[a, b]); + expect(order, ['100:0', '100:1', '100:2', '101:0', '101:1', '101:2']); + }); + }); + + group('regressions confirmed by adversarial review', () { + // PROBE A: an image completes; a DIFFERENT image reusing the same img_id + // arrives within the TTL. The recently-completed shortcut used to report + // every chunk of it as `duplicate`, so onImage never fired, onFailed never + // fired, and the image was lost with no diagnostic. ImageIdAllocator seeds + // from Random().nextInt(256), so a restart really can re-roll a live id. + test('a new image reusing a just-completed img_id is not swallowed', () { + final delivered = []; + final failed = []; + final r = ImageReassembler( + onImage: delivered.add, + onFailed: failed.add, + ); + + final first = buildImageChunks( + payload: payloadOf(200, seed: 1), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 42, + ); + feed(r, first.blobs); + expect(delivered, hasLength(1), reason: 'first image should complete'); + + // Same sender, same img_id, different content and a different length. + final second = buildImageChunks( + payload: payloadOf(300, seed: 99), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 42, + ); + feed(r, second.blobs); + + expect(delivered, hasLength(2), reason: 'second image must not be lost'); + expect(delivered.last.data, equals(payloadOf(300, seed: 99))); + expect(failed, isEmpty); + }); + + test('a genuine re-send of a delivered chunk is still a duplicate', () { + final delivered = []; + final r = ImageReassembler(onImage: delivered.add); + final set = buildImageChunks( + payload: payloadOf(200, seed: 1), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 42, + ); + feed(r, set.blobs); + expect(delivered, hasLength(1)); + + // Replay every blob verbatim: none may open a new stream. + for (final blob in set.blobs) { + final outcome = r.addChunk(blob, channelIndex: 0); + expect(outcome.status, ImageChunkStatus.duplicate); + } + expect(delivered, hasLength(1)); + expect(r.pendingCount, 0); + }); + + // PROBE B/C: a flipped bit in the parity length byte used to yield a + // silently TRUNCATED image reported as `completed`. Only the last data + // chunk may be short. + test('corrupt parity length is rejected rather than silently truncating', + () { + for (final payloadLen in [300, 400]) { + final set = buildImageChunks( + payload: payloadOf(payloadLen, seed: 3), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 7, + ); + final data = set.blobs.sublist(0, set.dataChunkCount); + final parity = Uint8List.fromList(set.blobs.last); + // Corrupt the len_xor byte (first body byte, just after the header). + parity[kImageChunkHeaderBytes] ^= 0x02; + + final delivered = []; + final r = ImageReassembler(onImage: delivered.add); + // Drop a NON-FINAL chunk (index 1 of >=3, else index 0) and supply + // the corrupted parity. + final dropIndex = data.length >= 3 ? 1 : 0; + final kept = [ + for (var i = 0; i < data.length; i++) + if (i != dropIndex) data[i], + parity, + ]; + feed(r, kept); + expect( + delivered, + isEmpty, + reason: 'payload $payloadLen: truncated recovery must not complete', + ); + } + }); + + test('parity still recovers a genuinely lost non-final chunk', () { + final payload = payloadOf(400, seed: 5); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 8, + ); + final data = set.blobs.sublist(0, set.dataChunkCount); + expect(data.length, greaterThanOrEqualTo(3)); + + final delivered = []; + final r = ImageReassembler(onImage: delivered.add); + feed(r, [ + for (var i = 0; i < data.length; i++) + if (i != 1) data[i], + set.blobs.last, + ]); + expect(delivered, hasLength(1)); + expect(delivered.single.data, equals(payload)); + expect(delivered.single.recoveredWithParity, isTrue); + }); + }); + + + group('completed-image map is capped', () { + /// A lone parity chunk of a `total == 1` image completes that image by + /// itself — one packet, one remembered entry. That is the amplification the + /// cap exists to bound. + List loneParity(int imgId) => buildImageChunks( + payload: payloadOf(20, seed: imgId), + metadata: stdMeta, + senderPrefix: senderA, + imgId: imgId, + ).blobs; + + test('one packet per img_id can complete an image (the attack)', () { + final r = ImageReassembler(); + final done = r.addChunk(loneParity(1)[1]); + expect(done.status, ImageChunkStatus.completed); + expect(r.completedCount, 1); + expect(r.pendingCount, 0); + }); + + test('the map never exceeds maxCompletedStreams and evicts oldest first', + () { + final start = DateTime(2026, 3, 3); + final r = ImageReassembler(maxCompletedStreams: 3); + for (var i = 0; i < 20; i++) { + r.addChunk( + loneParity(100 + i)[1], + now: start.add(Duration(seconds: i)), + ); + expect(r.completedCount, lessThanOrEqualTo(3)); + } + expect(r.completedCount, 3); + expect( + r.completedKeys.map((k) => k.imgId).toList()..sort(), + [117, 118, 119], + ); + }); + + test('default cap matches the pending cap', () { + final start = DateTime(2026, 3, 4); + final r = ImageReassembler(); + expect(r.maxCompletedStreams, 8); + expect(r.maxConcurrentStreams, 8); + for (var i = 0; i < 30; i++) { + r.addChunk(loneParity(i)[1], now: start.add(Duration(seconds: i))); + } + expect(r.completedCount, 8); + }); + + test('capping does not break straggler suppression for recent images', () { + final start = DateTime(2026, 3, 5); + final r = ImageReassembler(maxCompletedStreams: 2); + final set = buildImageChunks( + payload: payloadOf(300), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 200, + ); + feed(r, set.blobs.sublist(0, 2), now: start); + // Trailing parity of the newest image is still recognised as a duplicate. + expect( + r.addChunk(set.blobs[2], now: start).status, + ImageChunkStatus.duplicate, + ); + expect(r.pendingCount, 0); + }); + + test('clear() empties the completed map too', () { + final r = ImageReassembler(); + r.addChunk(loneParity(9)[1]); + expect(r.completedCount, 1); + r.clear(); + expect(r.completedCount, 0); + }); + }); + + group('rate point wire codes', () { + test('the chunk-0 nibble is not the AeicRatePoint ordinal', () { + // The trap: AeicRatePoint.wireValue is 0..4 and selects a MODEL; ft32 is 4 + // there but 0 in the chunk-0 metadata nibble. If the two were ever + // conflated, ft32 would go on air as 4. + expect(AeicRatePoint.values.length, 5); + expect(AeicRatePoint.ft32.wireValue, 4); + expect(aeicRatePointForUi(ImageCodecRatePoint.standard), + AeicRatePoint.ft32); + expect(imageRateWireCode(ImageCodecRatePoint.standard), + kImageRateWireStandard); + expect(kImageRateWireStandard, 0); + expect(kImageRateWireHigh, 1); + expect( + const ImageStreamMetadata(rate: ImageCodecRatePoint.standard).encode() & + 0x0F, + 0, + ); + // ...and an AeicRatePoint ordinal on the wire is refused outright. + expect(imageRatePointFromWireCode(AeicRatePoint.ft32.wireValue), isNull); + expect(imageRatePointFromWireCode(AeicRatePoint.ft8.wireValue), isNull); + }); + + test('every wire code round-trips and unknown codes are null', () { + for (final rate in ImageCodecRatePoint.values) { + final code = imageRateWireCode(rate); + expect(code, lessThan(kImageRateWireCodeCount)); + expect(imageRatePointFromWireCode(code), rate); + } + expect( + ImageCodecRatePoint.values.map(imageRateWireCode).toSet(), + hasLength(ImageCodecRatePoint.values.length), + ); + for (var code = kImageRateWireCodeCount; code < 16; code++) { + expect(imageRatePointFromWireCode(code), isNull, reason: 'code $code'); + } + }); + + test('an unknown rate code fails cleanly instead of decoding wrong', () { + final payload = payloadOf(300); + final set = buildImageChunks( + payload: payload, + metadata: stdMeta, + senderPrefix: senderA, + imgId: 80, + ); + // Overwrite the 2-bit rate field with 2 — a code no shipping build + // emits. Without an explicit rate table this would have decoded as a + // valid rate point and fed the wrong model. + final bad = Uint8List.fromList(set.blobs[0]); + bad[kImageChunkHeaderBytes] = (bad[kImageChunkHeaderBytes] & 0xFC) | 0x02; + + final delivered = []; + final failures = []; + final r = ImageReassembler(onImage: delivered.add, onFailed: failures.add); + expect(r.addChunk(bad).status, ImageChunkStatus.accepted); + final done = r.addChunk(set.blobs[1]); + expect(done.status, ImageChunkStatus.unsupportedFormat); + expect(done.result, isNull); + expect(delivered, isEmpty); + expect(failures, hasLength(1)); + expect( + failures.single.reason, + ImageReassemblyFailureReason.unsupportedFormat, + ); + expect(failures.single.isCorrupt, isTrue); + expect(r.pendingCount, 0); + }); + + test('a reserved rate code fails the same way through reassembly', () { + // Resolution is a closed 2-bit field now, so it cannot carry an unknown + // value; the reserved RATE codes are what a future format must use to be + // rejected rather than misread. This asserts the rejection survives the + // whole reassembly path, not just ImageStreamMetadata.decode. + final set = buildImageChunks( + payload: payloadOf(300), + metadata: stdMeta, + senderPrefix: senderA, + imgId: 81, + ); + final bad = Uint8List.fromList(set.blobs[0]); + bad[kImageChunkHeaderBytes] = + (bad[kImageChunkHeaderBytes] & 0xFC) | 0x03; // reserved rate code 3 + final r = ImageReassembler(); + r.addChunk(bad); + expect( + r.addChunk(set.blobs[1]).status, + ImageChunkStatus.unsupportedFormat, + ); + }); + + }); + + group('metadata byte: aspect ratio', () { + // The codec stretches the WHOLE frame into 512x512 rather than cropping, so + // nothing outside the frame is lost — but the stretch is not invertible + // from pixels alone. The sender names the source shape in spare bits of the + // metadata byte and the receiver letterboxes back. + test('round-trips every aspect code with rate and resolution intact', () { + for (var code = 0; code < kImageAspectCodes.length; code++) { + final m = ImageStreamMetadata( + rate: ImageCodecRatePoint.standard, + squareSize: 512, + aspectCode: code, + ); + final decoded = ImageStreamMetadata.decode(m.encode()); + expect(decoded, isNotNull, reason: 'aspect code $code'); + expect(decoded!.aspectCode, code); + expect(decoded.rate, ImageCodecRatePoint.standard); + expect(decoded.squareSize, 512); + } + }); + + test('the whole byte still fits in 8 bits for every legal combination', () { + for (final size in kImageResolutionCodes) { + for (final rate in ImageCodecRatePoint.values) { + for (var code = 0; code < kImageAspectCodes.length; code++) { + final byte = ImageStreamMetadata( + rate: rate, + squareSize: size, + aspectCode: code, + ).encode(); + expect(byte, inInclusiveRange(0, 255)); + final back = ImageStreamMetadata.decode(byte)!; + expect(back.squareSize, size); + expect(back.rate, rate); + expect(back.aspectCode, code); + } + } + } + }); + + test('common phone shapes snap exactly, not approximately', () { + expect(imageAspectCodeFor(4032, 3024), 2); // 4:3 + expect(imageAspectCodeFor(3024, 4032), 9); // 3:4 + expect(imageAspectCodeFor(1920, 1080), 5); // 16:9 + expect(imageAspectCodeFor(1080, 1920), 12); // 9:16 + expect(imageAspectCodeFor(1000, 1000), 0); // 1:1 + // A ratio between table entries still picks the nearest. + expect(kImageAspectCodes[imageAspectCodeFor(1500, 1000)], [3, 2]); + }); + + test('extremes and nonsense degrade to unknown, not a wrong shape', () { + expect(imageAspectCodeFor(4000, 500), kImageAspectUnknown); // 8:1 pano + expect(imageAspectCodeFor(500, 4000), kImageAspectUnknown); + expect(imageAspectCodeFor(0, 100), kImageAspectUnknown); + expect(imageAspectCodeFor(100, 0), kImageAspectUnknown); + expect(imageAspectCodeFor(-4, 3), kImageAspectUnknown); + final m = ImageStreamMetadata( + rate: ImageCodecRatePoint.standard, + aspectCode: kImageAspectUnknown, + ); + expect(m.isSquare, isTrue); + expect(m.aspectRatio, 1.0); + }); + + test('aspectRatio reconstructs the source shape', () { + final m = ImageStreamMetadata( + rate: ImageCodecRatePoint.standard, + aspectCode: imageAspectCodeFor(1920, 1080), + ); + expect(m.aspectRatio, closeTo(16 / 9, 1e-9)); + expect(m.isSquare, isFalse); + }); + }); +} diff --git a/test/image_wiring_test.dart b/test/image_wiring_test.dart new file mode 100644 index 00000000..5de76b4e --- /dev/null +++ b/test/image_wiring_test.dart @@ -0,0 +1,107 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/main.dart'; +import 'package:meshcore_open/services/image_chunk_transport.dart'; +import 'package:meshcore_open/services/received_image_store.dart'; +import 'package:meshcore_open/models/app_settings.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; + +void main() { + test('ImageStreamReassembler.selfPrefix override suppresses our own echo', () { + final store = ReceivedImageStore(); + final r = ImageStreamReassembler(store: store); + final set = buildImageChunks( + payload: Uint8List.fromList(List.generate(120, (i) => i)), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: 0xABCD, + imgId: 7, + ); + // Before SELF_INFO the prefix is unknown, so nothing is suppressed. + expect(r.addChunk(set.blobs[0], channelIndex: 1).status, + ImageChunkStatus.completed); + r.clear(); + // After SELF_INFO the superclass must see the overridden getter. + r.selfPrefix = 0xABCD; + expect(r.addChunk(set.blobs[0], channelIndex: 1).status, + ImageChunkStatus.fromSelf); + }); + + test('addChunk forwards outcomes to the store with the channel index', + () async { + final store = ReceivedImageStore(); + final r = ImageStreamReassembler(store: store); + final set = buildImageChunks( + payload: Uint8List.fromList(List.generate(400, (i) => i & 0xFF)), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: 0x1234, + imgId: 3, + ); + r.addChunk(set.blobs[0], channelIndex: 5); + await store.settle(); + final entry = store.entries.single; + expect(entry.channelIndex, 5); + expect(entry.senderPrefix, 0x1234); + expect(entry.state, ReceivedImageState.receiving); + }); + + group('AppSettings image codec block', () { + test('round-trips through toJson/fromJson on the ImageCodecPreferences keys', + () { + final settings = AppSettings( + imageCodecEnabled: true, + imageCodecSelectedModelId: 'qdq_conv_pct_novae', + imageCodecModelSourceUrl: 'https://example.invalid/model.onnx', + imageCodecRatePoint: 4, + imageCodecDownloadedModels: [ + ImageCodecModelRecord( + id: 'qdq_conv_pct_novae', + name: 'AEIC ft32 (int8)', + sourceUrl: 'https://example.invalid/model.onnx', + localPath: '/tmp/model.onnx', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(1000), + fileSizeBytes: 872 * 1024 * 1024, + ), + ], + ); + final json = settings.toJson(); + // The keys must be the ones ImageCodecPreferences already used, so a + // build that wrote them through the old standalone store still reads. + expect(json['image_codec_enabled'], isTrue); + expect(json['image_codec_selected_model_id'], 'qdq_conv_pct_novae'); + expect(json['image_codec_rate_point'], 4); + + final restored = AppSettings.fromJson(json); + expect(restored.imageCodecEnabled, isTrue); + expect(restored.imageCodecSelectedModelId, 'qdq_conv_pct_novae'); + expect( + restored.imageCodecModelSourceUrl, + 'https://example.invalid/model.onnx', + ); + expect(restored.imageCodecRatePoint, 4); + expect(restored.imageCodecDownloadedModels.single.localPath, + '/tmp/model.onnx'); + }); + + test('defaults are off, ft32, and empty', () { + const prefs = ImageCodecPreferences(); + final settings = AppSettings(); + expect(settings.imageCodecEnabled, prefs.enabled); + expect(settings.imageCodecRatePoint, prefs.ratePoint); + expect(settings.imageCodecDownloadedModels, isEmpty); + expect(settings.imageCodec.aeicRatePoint, AeicRatePoint.ft32); + }); + + test('the assembled view matches the five fields', () { + final settings = AppSettings( + imageCodecEnabled: true, + imageCodecSelectedModelId: 'x', + imageCodecRatePoint: 4, + ); + expect(settings.imageCodec.enabled, isTrue); + expect(settings.imageCodec.selectedModelId, 'x'); + expect(settings.imageCodec.ratePoint, 4); + }); + }); +} diff --git a/test/lora_airtime_test.dart b/test/lora_airtime_test.dart new file mode 100644 index 00000000..52d1e660 --- /dev/null +++ b/test/lora_airtime_test.dart @@ -0,0 +1,510 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/image_chunk_transport.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; +import 'package:meshcore_open/models/radio_settings.dart'; +import 'package:meshcore_open/utils/lora_airtime.dart'; + +double _ms(Duration d) => d.inMicroseconds / 1000.0; + +RadioSettings _radio({ + LoRaSpreadingFactor sf = LoRaSpreadingFactor.sf10, + LoRaBandwidth bw = LoRaBandwidth.bw250, + LoRaCodingRate cr = LoRaCodingRate.cr4_5, +}) => + RadioSettings( + frequencyMHz: 869.525, + bandwidth: bw, + spreadingFactor: sf, + codingRate: cr, + txPowerDbm: 22, + ); + +void main() { + group('loraTimeOnAir reference values (255-byte packet)', () { + test('SF9, CR 4/8, BW 250 kHz -> 975 ms', () { + final toa = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 8, + ); + expect(_ms(toa), closeTo(975, 1)); + }); + + test('SF10, CR 4/5, BW 250 kHz -> 1148 ms', () { + final toa = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 10, + bandwidthHz: 250000, + codingRate: 5, + ); + expect(_ms(toa), closeTo(1148, 1)); + }); + }); + + group('low data rate optimize', () { + test('SF12 / BW 125 kHz engages LDRO (Tsym = 32.768 ms > 16 ms)', () { + final toa = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 12, + bandwidthHz: 125000, + codingRate: 5, + ); + // DE = 1 -> denominator 4*(12-2) = 40 -> 51 * 5 = 255 payload symbols + // ToA = (12.25 + 263) * 32.768 ms + expect(_ms(toa), closeTo(9019.392, 1)); + }); + + test('SF12 / BW 500 kHz does NOT engage LDRO (Tsym = 8.192 ms)', () { + final toa = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 12, + bandwidthHz: 500000, + codingRate: 5, + ); + // DE = 0 -> denominator 48 -> 43 * 5 = 215 payload symbols + // ToA = (12.25 + 223) * 8.192 ms + expect(_ms(toa), closeTo(1927.9296, 1)); + }); + + test('SF11 / BW 250 kHz does NOT engage LDRO (Tsym = 8.192 ms)', () { + // Guards against the common `sf >= 11` shortcut, which is wrong here. + final withSf11 = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 11, + bandwidthHz: 250000, + codingRate: 5, + ); + // DE = 0 -> denominator 44 -> 47 * 5 = 235 payload symbols + // ToA = (12.25 + 243) * 8.192 ms + expect(_ms(withSf11), closeTo(2091.008, 1)); + }); + }); + + group('airtime monotonicity / sanity', () { + test('longer payload never takes less airtime', () { + Duration at(int pl) => loraTimeOnAir( + payloadBytes: pl, + spreadingFactor: 10, + bandwidthHz: 250000, + codingRate: 5, + ); + var previous = at(0); + for (var pl = 1; pl <= 255; pl++) { + final current = at(pl); + expect(current.inMicroseconds, + greaterThanOrEqualTo(previous.inMicroseconds)); + previous = current; + } + }); + + test('zero and one byte payloads do not crash and are positive', () { + for (final pl in [0, 1]) { + final toa = loraTimeOnAir( + payloadBytes: pl, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 5, + ); + expect(toa.inMicroseconds, greaterThan(0)); + } + }); + }); + + group('normalizeCodingRate', () { + test('maps both firmware encodings to 5..8', () { + expect(normalizeCodingRate(1), 5); + expect(normalizeCodingRate(4), 8); + expect(normalizeCodingRate(5), 5); + expect(normalizeCodingRate(8), 8); + }); + + test('raw 1..4 and 5..8 produce identical airtime after normalisation', () { + final a = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: normalizeCodingRate(4), + ); + final b = loraTimeOnAir( + payloadBytes: 255, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: normalizeCodingRate(8), + ); + expect(a, b); + expect(_ms(a), closeTo(975, 1)); + }); + }); + + group('chunk counts for measured codec payload sizes', () { + test('ft32 "standard" (110 / 155.8 / 209 bytes) -> 1-2 chunks', () { + // Derived from kImageChunkFirstCapacity, never hardcoded: that constant + // has already moved twice (2->4 byte header, then a 2-byte CRC added to + // chunk 0), and each time a hardcoded expectation here would have hidden + // the estimator drifting away from the chunker. + expect(imageChunkCount(110), 1); + expect(imageChunkCount(209), 2); + expect( + imageChunkCount(156), + 156 <= kImageChunkFirstCapacity ? 1 : 2, + ); + for (final pl in [110, 156, 209]) { + expect(imageChunkCount(pl), inInclusiveRange(1, 2)); + } + }); + + test('ft16 "high" (176 / 288 / 409 bytes) -> 2-3 chunks', () { + expect(imageChunkCount(176), 2); + expect(imageChunkCount(288), 2); + expect(imageChunkCount(409), 3); + for (final pl in [176, 288, 409]) { + expect(imageChunkCount(pl), inInclusiveRange(2, 3)); + } + }); + + test('chunk 0 carries one fewer payload byte (boundary handling)', () { + // Derived from the transport's constants, never hardcoded: these numbers + // moved once already when the header grew from 2 to 4 bytes to carry the + // sender prefix, and a hardcoded test hid the estimator disagreeing with + // the chunker. + const first = kImageChunkFirstCapacity; + const rest = kImageChunkCapacity; + expect(first, rest - kImageChunkZeroMetadataBytes); + expect(imageChunkCount(first), 1); + expect(imageChunkCount(first + 1), 2); + expect(imageChunkCount(first + rest), 2); + expect(imageChunkCount(first + rest + 1), 3); + expect(imageChunkCount(0), 0); + expect(imageChunkCount(1), 1); + }); + + test('chunk payload sizes sum to the payload', () { + for (final pl in [0, 1, 110, 162, 163, 209, 288, 409, 1000]) { + expect(imageChunkPayloadSizes(pl).fold(0, (a, b) => a + b), pl); + } + }); + }); + + group('estimateSend', () { + test('parity adds exactly one packet', () { + final radio = _radio(); + for (final pl in [110, 209, 288, 409]) { + final without = + estimateSend(payloadBytes: pl, radio: radio, parity: false); + final with_ = estimateSend(payloadBytes: pl, radio: radio); + expect(with_.chunkCount, without.chunkCount + 1); + expect(without.includesParity, isFalse); + expect(with_.includesParity, isTrue); + expect(with_.totalAirtime!.inMicroseconds, + greaterThan(without.totalAirtime!.inMicroseconds)); + } + }); + + test('unknown radio settings -> packet count kept, airtime null', () { + final est = estimateSend(payloadBytes: 288, radio: null); + expect(est.chunkCount, 3); // 2 data chunks + parity + expect(est.totalBytes, greaterThan(288)); + expect(est.perPacketAirtime, isNull); + expect(est.totalAirtime, isNull); + expect(est.hasAirtime, isFalse); + }); + + test('partially unknown radio params also yield a null airtime', () { + final est = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 10, + bandwidthHz: null, + codingRate: 5, + ); + expect(est.chunkCount, 3); + expect(est.hasAirtime, isFalse); + }); + + test('raw firmware coding rate 1..4 is normalised', () { + final a = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 4, // firmware 1..4 encoding for 4/8 + ); + final b = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 8, // 5..8 encoding for 4/8 + ); + expect(a, b); + }); + + test('zero-byte payload does not crash and adds no parity', () { + final est = estimateSend(payloadBytes: 0, radio: _radio()); + expect(est.chunkCount, 0); + expect(est.totalBytes, 0); + expect(est.includesParity, isFalse); + expect(est.totalAirtime, Duration.zero); + }); + + test('one-byte payload is a single chunk plus parity', () { + final est = estimateSend(payloadBytes: 1, radio: _radio()); + expect(est.chunkCount, 2); + expect(est.totalAirtime!.inMicroseconds, greaterThan(0)); + }); + + test('total bytes account for chunk headers and metadata', () { + final est = + estimateSend(payloadBytes: 209, radio: _radio(), parity: false); + // A data blob is header + body (chunk 0's body opens with the metadata + // byte). Only the PARITY blob carries the length byte, and it is always a + // full kImageChunkBlobBytes because the XOR body is zero-padded. + final sizes = imageChunkPayloadSizes(209); + var expected = 0; + for (var i = 0; i < sizes.length; i++) { + expected += kImageChunkHeaderBytes + + (i == 0 ? kImageChunkZeroMetadataBytes : 0) + + sizes[i]; + } + expect(est.chunkCount, 2); + expect(est.totalBytes, expected); + // Sanity: payload + per-chunk header + the one metadata byte. + expect( + expected, + 209 + 2 * kImageChunkHeaderBytes + kImageChunkZeroMetadataBytes, + ); + }); + + test('total airtime equals the sum of the per-chunk airtimes', () { + final est = estimateSend( + payloadBytes: 409, + radio: _radio(sf: LoRaSpreadingFactor.sf9, cr: LoRaCodingRate.cr4_8), + parity: false, + ); + final sizes = imageChunkPayloadSizes(409); + var expected = 0; + for (var i = 0; i < sizes.length; i++) { + expected += loraTimeOnAir( + payloadBytes: kImageChunkHeaderBytes + + (i == 0 ? kImageChunkZeroMetadataBytes : 0) + + sizes[i], + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 8, + ).inMicroseconds; + } + expect(est.totalAirtime!.inMicroseconds, expected); + }); + + test('per-packet airtime is the airtime of a full chunk packet', () { + final est = estimateSend( + payloadBytes: 409, + radio: _radio(sf: LoRaSpreadingFactor.sf10, cr: LoRaCodingRate.cr4_5), + ); + final full = loraTimeOnAir( + payloadBytes: kImageChunkBlobBytes, + spreadingFactor: 10, + bandwidthHz: 250000, + codingRate: 5, + ); + expect(est.perPacketAirtime, full); + }); + + test('a realistic ft16 image on SF10/BW250/CR4-5 stays under ~5 s', () { + final est = estimateSend(payloadBytes: 288, radio: _radio()); + expect(est.chunkCount, 3); + expect(est.totalAirtime!.inMilliseconds, greaterThan(1000)); + expect(est.totalAirtime!.inMilliseconds, lessThan(5000)); + }); + }); + + group('paced wall clock', () { + test('single-packet send has no pacing gap', () { + final est = + estimateSend(payloadBytes: 110, radio: _radio(), parity: false); + expect(est.chunkCount, 1); + expect(est.pacedWallClock, est.totalAirtime); + }); + + test('multi-packet send adds one gap per inter-packet boundary', () { + final est = estimateSend(payloadBytes: 209, radio: _radio()); + expect(est.chunkCount, 3); // 2 data + parity + final sizes = imageChunkPayloadSizes(209); + const framing = kImageChunkHeaderBytes + kImageParityLengthBytes; + final packetBytes = [ + framing + kImageChunkZeroMetadataBytes + sizes[0], + framing + sizes[1], + // parity body is as large as the largest data body + framing + + (sizes[0] + kImageChunkZeroMetadataBytes > sizes[1] + ? sizes[0] + kImageChunkZeroMetadataBytes + : sizes[1]), + ]; + var airtime = 0; + var wall = 0; + for (var i = 0; i < packetBytes.length; i++) { + final toa = loraTimeOnAir( + payloadBytes: packetBytes[i], + spreadingFactor: 10, + bandwidthHz: 250000, + codingRate: 5, + ); + airtime += toa.inMicroseconds; + wall += toa.inMicroseconds; + if (i != packetBytes.length - 1) { + wall += imageSendChunkGap(toa).inMicroseconds; + } + } + expect(est.totalAirtime!.inMicroseconds, airtime); + expect(est.pacedWallClock!.inMicroseconds, wall); + // Two boundaries, so at least two base delays of extra wall clock. + expect( + wall - airtime, + greaterThanOrEqualTo(2 * kImageSendChunkGapBase.inMicroseconds), + ); + }); + + test('the gap is the documented base plus airtime factor', () { + const toa = Duration(milliseconds: 300); + expect( + imageSendChunkGap(toa), + Duration( + microseconds: kImageSendChunkGapBase.inMicroseconds + + (toa.inMicroseconds * kImageSendChunkGapAirtimeFactor).round(), + ), + ); + }); + + test('unknown radio settings leave the wall clock null too', () { + final est = estimateSend(payloadBytes: 156, radio: null); + expect(est.pacedWallClock, isNull); + expect(est.totalAirtime, isNull); + expect(est.chunkCount, imageChunkCount(156) + 1); // + parity + }); + + test('a realistic ft32 image on SF10/BW250/CR4-5 is a few seconds', () { + // 110-209 B measured => 1-2 data chunks + parity. The paced figure is + // what the compose sheet shows, so it must stay plausible. + for (final pl in [110, 156, 209]) { + final est = estimateSend(payloadBytes: pl, radio: _radio()); + expect(est.chunkCount, inInclusiveRange(2, 3)); + expect(est.pacedWallClock!.inMilliseconds, greaterThan(1000)); + expect(est.pacedWallClock!.inMilliseconds, lessThan(10000)); + expect( + est.pacedWallClock!.inMicroseconds, + greaterThan(est.totalAirtime!.inMicroseconds), + ); + } + }); + }); + + group('malformed radio parameters from the wire', () { + // currentSf/currentBwHz/currentCr are raw bytes off the device. A + // disconnected or half-initialised radio reports zeroes, which used to + // reach the ToA maths and throw "Unsupported operation: Infinity or NaN + // toInt" in release builds. Packet counts must survive; airtime must go + // null rather than be invented. + test('zero spreading factor yields packet counts but no airtime', () { + final est = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 0, + bandwidthHz: 250000, + codingRate: 5, + ); + expect(est.chunkCount, greaterThan(0)); + expect(est.totalBytes, greaterThan(0)); + expect(est.totalAirtime, isNull); + expect(est.perPacketAirtime, isNull); + }); + + test('zero bandwidth yields no airtime', () { + final est = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 9, + bandwidthHz: 0, + codingRate: 8, + ); + expect(est.totalAirtime, isNull); + }); + + test('zero coding rate yields no airtime', () { + // normalizeCodingRate(0) == 4, which is still outside the legal 5..8. + final est = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 0, + ); + expect(est.totalAirtime, isNull); + }); + + test('out-of-range spreading factors are rejected at both ends', () { + for (final sf in [4, 13, 255]) { + final est = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: sf, + bandwidthHz: 250000, + codingRate: 5, + ); + expect(est.totalAirtime, isNull, reason: 'sf=$sf must not produce airtime'); + } + }); + + test('valid params still produce airtime after the guard', () { + final est = estimateSendFromRadioParams( + payloadBytes: 288, + spreadingFactor: 9, + bandwidthHz: 250000, + codingRate: 4, // 1..4 firmware encoding -> normalises to 4/8 + ); + expect(est.totalAirtime, isNotNull); + expect(est.totalAirtime!.inMilliseconds, greaterThan(0)); + }); + + test('areLoRaParamsValid accepts the boundary values', () { + expect(areLoRaParamsValid(spreadingFactor: 5, bandwidthHz: 7800, codingRate: 5), isTrue); + expect(areLoRaParamsValid(spreadingFactor: 12, bandwidthHz: 500000, codingRate: 8), isTrue); + expect(areLoRaParamsValid(spreadingFactor: null, bandwidthHz: 250000, codingRate: 5), isFalse); + }); + }); + + group('estimator agrees with the real chunker', () { + // The estimator used to charge the parity-length byte to every data chunk + // and size the parity blob from the largest data body. Both were wrong: + // only parity carries that byte, and its XOR body is always zero-padded to + // full. A 110-byte payload was reported as 232 on-air bytes against a real + // 278 — a 17% understatement of airtime on the smallest, most common image. + // Compare against buildImageChunks() rather than restating the arithmetic. + for (final payload in [1, 110, 156, 157, 158, 209, 288, 409]) { + test('$payload-byte payload matches buildImageChunks byte for byte', () { + for (final parity in [false, true]) { + final set = buildImageChunks( + payload: Uint8List(payload), + metadata: const ImageStreamMetadata( + rate: ImageCodecRatePoint.standard, + ), + senderPrefix: 0x1234, + imgId: 7, + parity: parity, + ); + final actual = set.blobs.fold(0, (a, b) => a + b.length); + final est = estimateSend( + payloadBytes: payload, + radio: _radio(), + parity: parity, + ); + expect( + est.totalBytes, + actual, + reason: 'payload $payload, parity $parity', + ); + expect( + est.chunkCount, + set.blobs.length, + reason: 'payload $payload, parity $parity', + ); + } + }); + } + }); +} diff --git a/test/models/image_codec_support_test.dart b/test/models/image_codec_support_test.dart new file mode 100644 index 00000000..b5a05fc1 --- /dev/null +++ b/test/models/image_codec_support_test.dart @@ -0,0 +1,506 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; + +void main() { + group('ImageCodecModelRecord', () { + test('round-trips through JSON', () { + final record = ImageCodecModelRecord( + id: 'aeic-se-512-int8', + name: 'aeic_se_512_int8.onnx', + sourceUrl: 'https://example.invalid/model.onnx', + localPath: '/tmp/aeic_se_512_int8.onnx', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000), + fileSizeBytes: 873463808, + assetFileNames: ['a.onnx', 'a.onnx.data', 'e.onnx', 't.bin'], + bundleVersion: kImageCodecBundleVersion, + ); + + final restored = ImageCodecModelRecord.fromJson(record.toJson()); + + expect(restored.id, record.id); + expect(restored.name, record.name); + expect(restored.sourceUrl, record.sourceUrl); + expect(restored.localPath, record.localPath); + expect(restored.downloadedAt, record.downloadedAt); + expect(restored.fileSizeBytes, record.fileSizeBytes); + expect(restored.assetFileNames, record.assetFileNames); + expect(restored.bundleVersion, kImageCodecBundleVersion); + }); + + test('tolerates a missing/garbage payload', () { + final restored = ImageCodecModelRecord.fromJson({}); + expect(restored.id, ''); + expect(restored.fileSizeBytes, 0); + expect(restored.downloadedAt.millisecondsSinceEpoch, 0); + expect(restored.assetFileNames, isEmpty); + expect(restored.bundleVersion, 0); + }); + + test('a record written by a pre-bundle build reads as version 0', () { + // The exact JSON the decoder-only build wrote. It must still load, and it + // must be recognisable as needing an upgrade rather than silently + // claiming to be a full bundle. + final restored = ImageCodecModelRecord.fromJson({ + 'id': 'aeic-se-decoder-qdq-conv-pct-novae', + 'name': 'aeic_decoder_qdq_conv_pct_novae.onnx', + 'source_url': 'https://example.invalid/x.onnx', + 'local_path': '/tmp/aeic_decoder_qdq_conv_pct_novae.onnx', + 'downloaded_at': 1730000000000, + 'file_size_bytes': 2909610, + }); + expect(restored.bundleVersion, 0); + expect(restored.bundleVersion, lessThan(kImageCodecBundleVersion)); + expect(restored.assetFileNames, isEmpty); + expect(restored.localPath, isNotEmpty); + expect(restored.assetRoles, isEmpty); + }); + + test('asset roles survive JSON and tell the two entropy graphs apart', () { + // The whole point of the field: `e_send.onnx` and `e_decode.onnx` are + // indistinguishable by name, extension or position, and swapping them is + // the silent-corruption failure mode. + final record = ImageCodecModelRecord( + id: 'bundle', + name: 'd.onnx', + sourceUrl: 'https://example.invalid/d.onnx', + localPath: '/tmp/d.onnx', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000), + fileSizeBytes: 1, + assetFileNames: const [ + 'd.onnx', + 'd.onnx.data', + 'e_send.onnx', + 'e_decode.onnx', + 't.bin', + ], + assetRoles: const { + 'd.onnx': ImageCodecAssetRole.decoderGraph, + 'd.onnx.data': ImageCodecAssetRole.decoderWeights, + 'e_send.onnx': ImageCodecAssetRole.entropyGraph, + 'e_decode.onnx': ImageCodecAssetRole.entropyDecodeGraph, + 't.bin': ImageCodecAssetRole.cdfTables, + }, + bundleVersion: kImageCodecBundleVersion, + ); + + // Serialised by NAME, so appending an enum member cannot re-label a file + // that is already installed. + expect(record.toJson()['asset_roles'], { + 'd.onnx': 'decoderGraph', + 'd.onnx.data': 'decoderWeights', + 'e_send.onnx': 'entropyGraph', + 'e_decode.onnx': 'entropyDecodeGraph', + 't.bin': 'cdfTables', + }); + + final restored = ImageCodecModelRecord.fromJson(record.toJson()); + expect( + restored.fileNameForRole(ImageCodecAssetRole.entropyGraph), + 'e_send.onnx', + ); + expect( + restored.fileNameForRole(ImageCodecAssetRole.entropyDecodeGraph), + 'e_decode.onnx', + ); + expect( + restored.fileNameForRole(ImageCodecAssetRole.entropyWeights), + isNull, + ); + }); + + test('an unknown role name is dropped, not coerced to a real role', () { + // A record written by a newer build. Filing its unknown file under some + // existing role would hand ORT the wrong graph. + final restored = ImageCodecModelRecord.fromJson({ + 'asset_file_names': ['a.onnx', 'b.bin'], + 'asset_roles': {'a.onnx': 'somethingFromTheFuture', 'b.bin': 'cdfTables'}, + }); + expect(restored.assetRoles.keys, ['b.bin']); + expect(restored.fileNameForRole(ImageCodecAssetRole.cdfTables), 'b.bin'); + expect(parseImageCodecAssetRole('somethingFromTheFuture'), isNull); + expect( + parseImageCodecAssetRole('entropyDecodeGraph'), + ImageCodecAssetRole.entropyDecodeGraph, + ); + }); + + test('a role naming a file that is not installed does not resolve', () { + // assetRoles and assetFileNames can disagree if a file was reaped; the + // list of what is actually on disk wins. + final record = ImageCodecModelRecord( + id: 'x', + name: 'd.onnx', + sourceUrl: '', + localPath: '/tmp/d.onnx', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(0), + fileSizeBytes: 0, + assetFileNames: const ['d.onnx'], + assetRoles: const { + 'd.onnx': ImageCodecAssetRole.decoderGraph, + 'gone.onnx': ImageCodecAssetRole.entropyDecodeGraph, + }, + ); + expect( + record.fileNameForRole(ImageCodecAssetRole.entropyDecodeGraph), + isNull, + ); + }); + }); + + group('ImageCodecBundle', () { + test('is complete only with both entropy graph and tables', () { + const decoderOnly = ImageCodecBundle(decoderGraphPath: '/m/d.onnx'); + expect(decoderOnly.isComplete, isFalse); + const noTables = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + ); + expect(noTables.isComplete, isFalse); + const noEntropy = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + tablesPath: '/m/t.bin', + ); + expect(noEntropy.isComplete, isFalse); + const full = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + tablesPath: '/m/t.bin', + ); + expect(full.isComplete, isTrue); + }); + + test('supportsDecode additionally requires the decode-side graph', () { + // Bundle version 1: the send-side entropy graph only. It can encode -- + // that graph emits every stage at once, which is all an encoder needs -- + // but decoding is sequential and needs the If-branched export. + const sendOnly = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + tablesPath: '/m/t.bin', + ); + expect(sendOnly.isComplete, isTrue); + expect(sendOnly.supportsDecode, isFalse); + + const both = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + entropyDecodeGraphPath: '/m/e_dec.onnx', + tablesPath: '/m/t.bin', + ); + expect(both.supportsDecode, isTrue); + + // A decode-side graph without the tables is still useless. + const noTables = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + entropyDecodeGraphPath: '/m/e_dec.onnx', + ); + expect(noTables.supportsDecode, isFalse); + }); + + test('the decode-side graph participates in equality', () { + // The service keys its cached session on the bundle. If this field were + // left out of ==, upgrading a v1 install in place would reuse an isolate + // that never learned about the new graph. + const sendOnly = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + tablesPath: '/m/t.bin', + ); + const both = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + entropyDecodeGraphPath: '/m/e_dec.onnx', + tablesPath: '/m/t.bin', + ); + expect(both, isNot(sendOnly)); + expect(both.hashCode, isNot(sendOnly.hashCode)); + expect(both.toString(), contains('/m/e_dec.onnx')); + }); + + test('defaults to the shipping rate point and compares by value', () { + const a = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + tablesPath: '/m/t.bin', + ); + const b = ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + tablesPath: '/m/t.bin', + ); + expect(a.ratePoint, kShippingAeicRatePoint); + // The service keys its cached session on the bundle; value equality is + // what stops an identical bundle from respawning the isolate. + expect(a, b); + expect(a.hashCode, b.hashCode); + expect( + a, + isNot( + const ImageCodecBundle( + decoderGraphPath: '/m/d.onnx', + entropyGraphPath: '/m/e.onnx', + tablesPath: '/m/other.bin', + ), + ), + ); + }); + }); + + group('AeicRatePoint', () { + test('ordinals are the persisted/isolate wire values', () { + expect(AeicRatePoint.ft2.wireValue, 0); + expect(AeicRatePoint.ft32.wireValue, 4); + expect(parseAeicRatePoint(4), AeicRatePoint.ft32); + }); + + test('parse falls back to ft32 for out-of-range values', () { + expect(parseAeicRatePoint(-1), AeicRatePoint.ft32); + expect(parseAeicRatePoint(99), AeicRatePoint.ft32); + }); + + test('label carries the measured mean size where one exists', () { + expect(AeicRatePoint.ft32.label, 'ft32 (~156 B)'); + expect(AeicRatePoint.ft2.label, 'ft2'); + }); + + test('ft32 is the only shipping rate point', () { + expect(kShippingAeicRatePoint, AeicRatePoint.ft32); + // ft16 was dropped: 2-3 data chunks with 79 B of headroom. The UI mapping + // is constant now, so no selector value can reach another checkpoint. + for (final rate in ImageCodecRatePoint.values) { + expect(aeicRatePointForUi(rate), AeicRatePoint.ft32); + } + for (final rate in AeicRatePoint.values) { + expect(uiRatePointForAeic(rate), ImageCodecRatePoint.standard); + } + }); + + test('ft32 measurements are the real corpus numbers', () { + expect(AeicRatePoint.ft32.meanBytes, 156); // 155.8 B over 26 images + expect(AeicRatePoint.ft32.maxBytes, 209); + }); + + test('UI index and model ordinal are NOT interchangeable', () { + // Guards the trap documented on AeicRatePoint: the on-air nibble is + // ImageCodecRatePoint.index, the settings/isolate value is this ordinal. + expect( + ImageCodecRatePoint.standard.index, + isNot(AeicRatePoint.ft32.wireValue), + ); + }); + }); + + group('ImageCodecPreferences', () { + test('defaults to disabled at ft32 with no models', () { + const prefs = ImageCodecPreferences(); + expect(prefs.enabled, isFalse); + expect(prefs.aeicRatePoint, AeicRatePoint.ft32); + expect(prefs.downloadedModels, isEmpty); + }); + + test('round-trips through JSON including nested models', () { + final preset = imageCodecPresetModels.first; + final prefs = ImageCodecPreferences( + enabled: true, + selectedModelId: preset.id, + modelSourceUrl: preset.graph.sourceUrl, + ratePoint: AeicRatePoint.ft32.wireValue, + downloadedModels: [ + ImageCodecModelRecord( + id: preset.id, + name: preset.fileName, + sourceUrl: preset.graph.sourceUrl, + localPath: '/tmp/${preset.fileName}', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000), + fileSizeBytes: preset.graph.sizeBytes, + ), + ], + ); + + final restored = ImageCodecPreferences.fromJson(prefs.toJson()); + + expect(restored.enabled, isTrue); + expect(restored.selectedModelId, preset.id); + expect(restored.modelSourceUrl, prefs.modelSourceUrl); + expect(restored.aeicRatePoint, AeicRatePoint.ft32); + expect(restored.downloadedModels, hasLength(1)); + expect(restored.downloadedModels.first.id, preset.id); + }); + + test('copyWith can clear nullable strings via the sentinel', () { + const prefs = ImageCodecPreferences(selectedModelId: 'x'); + expect(prefs.copyWith().selectedModelId, 'x'); + expect(prefs.copyWith(selectedModelId: null).selectedModelId, isNull); + }); + }); + + group('imageCodecPresetModels', () { + test('ships exactly one model: one ft32 bundle, not two downloads', () { + expect(imageCodecPresetModels, hasLength(1)); + final preset = imageCodecPresetModels.single; + expect(preset.id, 'aeic-se-ft32-bundle-v1'); + expect(preset.ratePoint, AeicRatePoint.ft32); + expect(preset.isComplete, isTrue); + expect(preset.totalSizeBytes, kImageCodecBundleTotalBytes); + // 958.0 MiB across the five files, measured with stat on the local + // exports. See the upload manifest in image_codec_support.dart. + expect(preset.totalSizeBytes, 1004548432); + }); + + test('ships qdq_conv_pct, NOT the novae variant', () { + // The novae variant leaves the VAE in fp32: +38 MB on disk and +0.83 GiB + // peak RSS for 0.17 dB. RAM is the binding constraint on a phone. + final preset = imageCodecPresetModels.single; + expect(preset.fileName, 'aeic_decoder_qdq_conv_pct.onnx'); + for (final asset in preset.assets) { + expect(asset.fileName, isNot(contains('novae'))); + } + expect(preset.assetFor(ImageCodecAssetRole.decoderWeights).sizeBytes, + 872896480); + }); + + test('carries all five roles exactly once', () { + final preset = imageCodecPresetModels.single; + expect(preset.assets, hasLength(5)); + for (final role in const [ + ImageCodecAssetRole.decoderGraph, + ImageCodecAssetRole.decoderWeights, + ImageCodecAssetRole.entropyGraph, + ImageCodecAssetRole.entropyDecodeGraph, + ImageCodecAssetRole.cdfTables, + ]) { + expect( + preset.assets.where((a) => a.role == role), + hasLength(1), + reason: 'exactly one asset per role: $role', + ); + } + // Reserved and deliberately absent: the fp32 entropy export is + // self-contained. + expect(preset.maybeAssetFor(ImageCodecAssetRole.entropyWeights), isNull); + }); + + test('resolves the graph by role, not by list position', () { + final preset = imageCodecPresetModels.single; + expect(preset.graph, preset.assetFor(ImageCodecAssetRole.decoderGraph)); + expect(preset.graph.fileName, endsWith('.onnx')); + expect( + preset.assetFor(ImageCodecAssetRole.decoderWeights).fileName, + '${preset.graph.fileName}.data', + reason: 'ORT resolves external data by the exact filename in the graph', + ); + // A reordered list must not change which file ORT is handed. + final reordered = ImageCodecModelSpec( + id: preset.id, + label: preset.label, + assets: preset.assets.reversed.toList(), + ); + expect(reordered.fileName, preset.fileName); + }); + + test('the entropy graph and the tables are the ones that were validated', + () { + final preset = imageCodecPresetModels.single; + final entropy = preset.assetFor(ImageCodecAssetRole.entropyGraph); + // op17 fp32: byte-identical bitstreams on 26/26 images. Not op20, not int8. + expect(entropy.fileName, 'aeic_entropy_side_fp32_op17.onnx'); + expect(entropy.sizeBytes, 67262167); + // The decode-side export of the same weights. fp32 as well: int8 there + // breaks the bit-exactness the rANS decoder depends on. + final decode = preset.assetFor(ImageCodecAssetRole.entropyDecodeGraph); + expect(decode.fileName, 'aeic_entropy_decode_fp32_op17.onnx'); + expect(decode.sizeBytes, 60509540); + expect(decode.fileName, isNot(entropy.fileName)); + final tables = preset.assetFor(ImageCodecAssetRole.cdfTables); + expect(tables.fileName, 'aeic_cdf_ft32.bin'); + expect(tables.sizeBytes, 813648); + }); + + test('an incomplete spec is detectable', () { + final preset = imageCodecPresetModels.single; + final decoderOnly = ImageCodecModelSpec( + id: 'legacy', + label: 'Legacy', + assets: [ + preset.assetFor(ImageCodecAssetRole.decoderGraph), + preset.assetFor(ImageCodecAssetRole.decoderWeights), + ], + ); + expect(decoderOnly.isComplete, isFalse); + expect( + decoderOnly.maybeAssetFor(ImageCodecAssetRole.cdfTables), + isNull, + ); + expect( + () => decoderOnly.assetFor(ImageCodecAssetRole.cdfTables), + throwsStateError, + ); + + // A version-1 spec: everything except the decode-side graph. It would + // install a codec that can send and never receive, so downloadPresetModel + // must refuse it too. + final sendOnly = ImageCodecModelSpec( + id: 'v1', + label: 'Send-only', + assets: [ + for (final asset in preset.assets) + if (asset.role != ImageCodecAssetRole.entropyDecodeGraph) asset, + ], + ); + expect(sendOnly.assets, hasLength(4)); + expect(sendOnly.isComplete, isFalse); + }); + + test('uses the HuggingFace resolve/main URL shape', () { + for (final asset in imageCodecPresetModels.single.assets) { + expect(asset.sourceUrl, startsWith('https://huggingface.co/')); + expect(asset.sourceUrl, contains('/resolve/main/')); + expect(asset.sourceUrl, endsWith('?download=true')); + expect(asset.sourceUrl, contains(asset.fileName)); + } + }); + + test('friendly name resolves through the registry', () { + final preset = imageCodecPresetModels.single; + final record = ImageCodecModelRecord( + id: preset.id, + name: preset.fileName, + sourceUrl: '', + localPath: '/tmp/x', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(0), + fileSizeBytes: 0, + ); + expect(imageCodecModelFriendlyName(record), preset.label); + }); + + test('latent contract matches the export', () { + expect(kImageCodecLatentShape, [1, 256, 16, 16]); + expect(kImageCodecLatentElements, 65536); + expect(kImageCodecDecoderInputName, 'y_hat'); + }); + }); + + group('exceptions', () { + test('an incomplete install is NOT the same failure as a missing build', + () { + // Both are ImageCodecUnimplemented, but only one has a remedy the user + // can act on, and the UI branches on exactly that difference. + const incomplete = ImageCodecBundleIncomplete(); + const missing = ImageCodecEntropyPathMissing(); + expect(incomplete, isA()); + expect(missing, isA()); + expect(incomplete, isNot(isA())); + expect(incomplete.toString(), contains('re-download')); + }); + }); + + group('parseImageCodecStatus', () { + test('maps known values and defaults to none', () { + expect(parseImageCodecStatus('completed'), ImageCodecStatus.completed); + expect(parseImageCodecStatus('nonsense'), ImageCodecStatus.none); + expect(parseImageCodecStatus(42), ImageCodecStatus.none); + }); + }); +} diff --git a/test/services/entropy_tables_test.dart b/test/services/entropy_tables_test.dart new file mode 100644 index 00000000..45b48976 --- /dev/null +++ b/test/services/entropy_tables_test.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/entropy_tables.dart'; + +/// Structural checks on the shipped CDF table file. The invariants here are the +/// ones the rANS coder relies on; if any of them breaks, encoding silently +/// produces garbage rather than failing loudly. +void main() { + final Directory goldenDir = _resolveGoldenDir(); + final Uint8List raw = File( + '${goldenDir.path}/aeic_cdf_ft32.bin', + ).readAsBytesSync(); + final Map manifest = + jsonDecode(File('${goldenDir.path}/manifest.json').readAsStringSync()) + as Map; + final EntropyTables tables = EntropyTables.parse(raw); + + test('header matches the manifest', () { + expect(raw.length, manifest['table_bytes']); + expect(tables.version, 1); + expect(tables.precision, manifest['precision']); + expect(tables.bypassPrecision, manifest['bypass_precision']); + expect(tables.streamParts, manifest['stream_parts']); + expect(tables.groups.length, 2); + }); + + test('group shapes match the manifest', () { + final List> meta = + (manifest['table_groups'] as List) + .cast>(); + for (var g = 0; g < 2; g++) { + final CdfGroup group = tables.groups[g]; + expect(group.numCdfs, meta[g]['rows'], reason: 'group $g rows'); + expect(group.cdfWidth, meta[g]['width'], reason: 'group $g width'); + expect(group.quantizedCdf.length, group.numCdfs * group.cdfWidth); + + var lenMin = 1 << 30, lenMax = -(1 << 30); + var offMin = 1 << 30, offMax = -(1 << 30); + for (var r = 0; r < group.numCdfs; r++) { + lenMin = group.cdfLength[r] < lenMin ? group.cdfLength[r] : lenMin; + lenMax = group.cdfLength[r] > lenMax ? group.cdfLength[r] : lenMax; + offMin = group.offset[r] < offMin ? group.offset[r] : offMin; + offMax = group.offset[r] > offMax ? group.offset[r] : offMax; + } + expect(lenMin, meta[g]['cdf_length_min']); + expect(lenMax, meta[g]['cdf_length_max']); + expect(offMin, meta[g]['offset_min']); + expect(offMax, meta[g]['offset_max']); + } + expect(tables.zGroup.numCdfs, 128); + expect(tables.zGroup.cdfWidth, 19); + expect(tables.yGroup.numCdfs, 64); + expect(tables.yGroup.cdfWidth, 3133); + }); + + test('every CDF row is a valid, gap-free distribution', () { + for (var g = 0; g < tables.groups.length; g++) { + final CdfGroup group = tables.groups[g]; + for (var r = 0; r < group.numCdfs; r++) { + final int n = group.cdfLength[r]; + expect(n, greaterThanOrEqualTo(2), reason: 'group $g row $r length'); + expect(n, lessThanOrEqualTo(group.cdfWidth)); + expect(group.cdfAt(r, 0), 0, reason: 'group $g row $r first'); + expect( + group.cdfAt(r, n - 1), + 1 << 16, + reason: 'group $g row $r terminal', + ); + for (var c = 0; c + 1 < n; c++) { + final int gap = group.cdfAt(r, c + 1) - group.cdfAt(r, c); + expect( + gap, + greaterThanOrEqualTo(1), + reason: 'group $g row $r has a zero-frequency symbol at $c', + ); + } + for (var c = n; c < group.cdfWidth; c++) { + expect(group.cdfAt(r, c), 0, reason: 'group $g row $r padding at $c'); + } + } + } + }); + + test('index-quantizer block parses', () { + final IndexQuantizerParams p = tables.indexQuantizer; + expect(p.scalesLevels, 64); + expect(p.scaleTable.length, 64); + expect(p.logScaleMin, closeTo(-2.2072749131897207, 1e-15)); + expect(p.logScaleStep, closeTo(0.12305479932808384, 1e-15)); + expect(p.scaleThreshold, closeTo(0.08, 1e-7)); + expect(p.scaleFloor, closeTo(1e-5, 1e-11)); + expect(p.scaleTable.first, closeTo(0.11, 1e-5)); + expect(p.scaleTable.last, closeTo(256.0, 1e-3)); + for (var i = 1; i < p.scaleTable.length; i++) { + expect(p.scaleTable[i], greaterThan(p.scaleTable[i - 1])); + } + }); + + test('rejects a corrupt magic', () { + final Uint8List bad = Uint8List.fromList(raw.sublist(0, 4096)); + bad[3] ^= 0xFF; + expect( + () => EntropyTables.parse(bad), + throwsA(isA()), + ); + }); + + test('rejects a truncated file', () { + expect( + () => EntropyTables.parse(Uint8List.fromList(raw.sublist(0, 1024))), + throwsA(isA()), + ); + }); + + test('rejects trailing garbage', () { + final Uint8List extra = Uint8List(raw.length + 1)..setRange(0, raw.length, raw); + expect( + () => EntropyTables.parse(extra), + throwsA(isA()), + ); + }); +} + +Directory _resolveGoldenDir() { + for (final String candidate in [ + 'test/services/golden', + '../test/services/golden', + 'golden', + ]) { + final Directory d = Directory(candidate); + if (d.existsSync()) return d; + } + return Directory('test/services/golden'); +} diff --git a/test/services/golden/aeic_cdf_ft32.bin b/test/services/golden/aeic_cdf_ft32.bin new file mode 100644 index 00000000..e3b669dd Binary files /dev/null and b/test/services/golden/aeic_cdf_ft32.bin differ diff --git a/test/services/golden/e2e/image2.aeicrec b/test/services/golden/e2e/image2.aeicrec new file mode 100644 index 00000000..806bc1ec Binary files /dev/null and b/test/services/golden/e2e/image2.aeicrec differ diff --git a/test/services/golden/e2e/images.aeicrec b/test/services/golden/e2e/images.aeicrec new file mode 100644 index 00000000..e276c963 Binary files /dev/null and b/test/services/golden/e2e/images.aeicrec differ diff --git a/test/services/golden/e2e/kodim01.aeicrec b/test/services/golden/e2e/kodim01.aeicrec new file mode 100644 index 00000000..5b7c22bd Binary files /dev/null and b/test/services/golden/e2e/kodim01.aeicrec differ diff --git a/test/services/golden/e2e/kodim02.aeicrec b/test/services/golden/e2e/kodim02.aeicrec new file mode 100644 index 00000000..15190568 Binary files /dev/null and b/test/services/golden/e2e/kodim02.aeicrec differ diff --git a/test/services/golden/e2e/kodim05.aeicrec b/test/services/golden/e2e/kodim05.aeicrec new file mode 100644 index 00000000..af6cf927 Binary files /dev/null and b/test/services/golden/e2e/kodim05.aeicrec differ diff --git a/test/services/golden/e2e/manifest.json b/test/services/golden/e2e/manifest.json new file mode 100644 index 00000000..d7069acf --- /dev/null +++ b/test/services/golden/e2e/manifest.json @@ -0,0 +1,33 @@ +{ + "format": "aeic-entropy-e2e-recording", + "version": 1, + "checkpoint": "AEIC_SE_ft32.pkl", + "size": 512, + "files": [ + { + "file": "kodim01.aeicrec", + "bytes": 7378887, + "sha256": "dd4278523e2a51d031e11b3d71e90f9ffe863d1244bf3de3896525ff0da9c049" + }, + { + "file": "kodim02.aeicrec", + "bytes": 7378887, + "sha256": "8cc3043664c7c6d993baf6fd37db04f0e22954af114c1463b2000a5e230c2e20" + }, + { + "file": "kodim05.aeicrec", + "bytes": 7378927, + "sha256": "535eef3213b0fdb0238bebad7e3baa041fe62aae1623fd9ec2deebd06c11ce1c" + }, + { + "file": "image2.aeicrec", + "bytes": 7378902, + "sha256": "2779478763c22955bc0e61c22fb555dd0f016f3a5d3629a2272a66d1000d485e" + }, + { + "file": "images.aeicrec", + "bytes": 7378878, + "sha256": "d7153bfd8bb9a1f7bd764eb7fcb43b1932205ac1b43be5b2c6ef6c8a5c57edb1" + } + ] +} \ No newline at end of file diff --git a/test/services/golden/manifest.json b/test/services/golden/manifest.json new file mode 100644 index 00000000..3ce558d0 --- /dev/null +++ b/test/services/golden/manifest.json @@ -0,0 +1,486 @@ +{ + "checkpoint": "AEIC_SE_ft32.pkl", + "size": 512, + "precision": 16, + "bypass_precision": 2, + "stream_parts": 2, + "table_file": "aeic_cdf_ft32.bin", + "table_bytes": 813648, + "table_sha256": "4089fde2af16c340642a5c857be42f6d0f21caf71dd5b4f32d62efcd41c77bd5", + "table_groups": [ + { + "group": "z", + "rows": 128, + "width": 19, + "cdf_length_min": 19, + "cdf_length_max": 19, + "offset_min": -8, + "offset_max": -8, + "cdf_max": 65536 + }, + { + "group": "y", + "rows": 64, + "width": 3133, + "cdf_length_min": 5, + "cdf_length_max": 3133, + "offset_min": -1565, + "offset_max": -1, + "cdf_max": 65536 + } + ], + "z_cdf_group_index": 0, + "y_cdf_group_index": 1, + "images": [ + { + "image": "kodim01.png", + "stem": "kodim01", + "bitstream_file": "kodim01.bin", + "bitstream_bytes_stat": 136, + "bitstream_sha256": "771be01642e69c277e6dd6a1b5fe926d45990883e0a2d56a2fca67f5e29b385d", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 81, + 52 + ], + "substream_sha256": [ + "7803b0726155984d86876d260c72c7376fd1e24b49fa31e279e6c88b0b0b3e73", + "8b7e01993f42cd08cb08f3f75d2f94e186ea03e772d47a09a58796bf7db33b0b" + ], + "vector_file": "kodim01.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 13, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "kodim02.png", + "stem": "kodim02", + "bitstream_file": "kodim02.bin", + "bitstream_bytes_stat": 135, + "bitstream_sha256": "6d158062f4e940a09098fdae516eba541cf1c6c3c4aee39d95031add3bf33d7a", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 90, + 42 + ], + "substream_sha256": [ + "a9ab7f345e0733c799da943ac065a21bd407a00e946fab48ec1e1dd8b7bee468", + "7f765eebbaf5dfde47e1d1f70eb8a1407d55f59e0edf5839b378b9c3d7a39dd3" + ], + "vector_file": "kodim02.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 13, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "kodim05.png", + "stem": "kodim05", + "bitstream_file": "kodim05.bin", + "bitstream_bytes_stat": 173, + "bitstream_sha256": "65ba06f964e1f726f7a72f2c4e634282d60ed16232747bb4a84c1d73ed8411bc", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 119, + 51 + ], + "substream_sha256": [ + "12a97d8cd41b03930fa735d867f31756ba499e2d7220ea10f9b5ea96d1f15de3", + "c30c0be1d858848fcd6833a66bc6c3fae8b3dedcac463a598621794cef4ff894" + ], + "vector_file": "kodim05.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 14, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "kodim08.png", + "stem": "kodim08", + "bitstream_file": "kodim08.bin", + "bitstream_bytes_stat": 209, + "bitstream_sha256": "9e1747db3ec84a993e28b52923140948afbf93df483b4f20be93a0f79206ebc4", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 136, + 70 + ], + "substream_sha256": [ + "965f5fd268c6f367b998c827f95f090ba2d945d7e4ebe4003fb604c48711cd03", + "19fd4a704675ad1cd3f97aaf2670e7818d465d3fc800a03621dd11e167a77a9f" + ], + "vector_file": "kodim08.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 14, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "kodim13.png", + "stem": "kodim13", + "bitstream_file": "kodim13.bin", + "bitstream_bytes_stat": 118, + "bitstream_sha256": "75e13bfca9c13400c62aaab08648fa408a4e4cdb13d02ce3e64767f08728cc64", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 64, + 51 + ], + "substream_sha256": [ + "5b1b045e0c850df548bfdc3cf5777bcc1050d226365a5a3ab3dc1cdfd76cd077", + "81b80624237a8796034d94281be0aa3088eebf8124218d7f4a02257473d2fbcb" + ], + "vector_file": "kodim13.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 14, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "kodim19.png", + "stem": "kodim19", + "bitstream_file": "kodim19.bin", + "bitstream_bytes_stat": 170, + "bitstream_sha256": "0ac1e2f5981c7afb8c3efd5cc2c14acf4b53d949e4dd2f2701cf377068d62e0e", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 104, + 63 + ], + "substream_sha256": [ + "d9f566fbde8f3da32f738004a7e88764cf52d8df04c7905f53c0018057899e6f", + "790c717eb572cb07ac17c4324424293559a127de8299ffd4eaeb02f2d2e4f200" + ], + "vector_file": "kodim19.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 13, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "kodim23.png", + "stem": "kodim23", + "bitstream_file": "kodim23.bin", + "bitstream_bytes_stat": 206, + "bitstream_sha256": "1728c415e5a35f9fd501a35369166db8435e3fcfa2fabe99dcd38f21782f6ed7", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 135, + 68 + ], + "substream_sha256": [ + "b169be22ee0554ce83b47118534a25066c4dbb21b3c11e16e69c36eb905d485c", + "c3dd848ad555641febdbad1d5cfe7516965362e735eb36172fed096c1f8fa69e" + ], + "vector_file": "kodim23.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -2, + "z_q_max": 1, + "y_q_min": -2, + "y_q_max": 2, + "y_index_min": -1, + "y_index_max": 14, + "n_skipped_y_indexes": 1, + "roundtrip_bitexact": true + }, + { + "image": "kodim24.png", + "stem": "kodim24", + "bitstream_file": "kodim24.bin", + "bitstream_bytes_stat": 154, + "bitstream_sha256": "9c397ca0212cc6aeaea687c2fc8dc3b88a95a6c153108a056e4fc7254a21b371", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 91, + 60 + ], + "substream_sha256": [ + "4d83ea0c3d9f58795068962f02a9eea9d7ad91afaebe0e330c20e95f695e22c1", + "65c7cdc5f581934b21ea8a16aff1a67dd36118c58933997fc776b99c7f317525" + ], + "vector_file": "kodim24.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 2, + "y_index_min": 0, + "y_index_max": 14, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + }, + { + "image": "image2.webp", + "stem": "image2", + "bitstream_file": "image2.bin", + "bitstream_bytes_stat": 147, + "bitstream_sha256": "258fe68ff7bb9ad41e1b3c7f885c56d051b3d55d313e5e5cc04b4faaf3b181e0", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 88, + 56 + ], + "substream_sha256": [ + "60c3ec394f474c060bfe51f4a086ab40d6f409ab4694ff8b02b68f15029116be", + "d874295cab097586ca1da2026a89a694051baf832fe3e4cd52f03fafbdf896c8" + ], + "vector_file": "image2.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -3, + "z_q_max": 2, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": -1, + "y_index_max": 16, + "n_skipped_y_indexes": 1, + "roundtrip_bitexact": true + }, + { + "image": "images.jpeg", + "stem": "images", + "bitstream_file": "images.bin", + "bitstream_bytes_stat": 128, + "bitstream_sha256": "2ad2224c85c25c394daf0c6e35d0c8fba194b689863f42ebe85a0d67dd632869", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 75, + 50 + ], + "substream_sha256": [ + "706a74709539f2d63f0b9b58d78920c9dfe995a804a4af19ded31b0c51f9adde", + "d35ab3320032adaad85343918c5e118fcb3aea20168024e433b608acc6d918ab" + ], + "vector_file": "images.gv", + "vector_bytes_stat": 270592, + "n_z_symbols": 2048, + "n_y_symbols_each": 16384, + "z_q_min": -1, + "z_q_max": 1, + "y_q_min": -1, + "y_q_max": 1, + "y_index_min": 0, + "y_index_max": 13, + "n_skipped_y_indexes": 0, + "roundtrip_bitexact": true + } + ], + "synthetic": [ + { + "name": "z_escape_exact", + "cdf_group": 0, + "n": 640, + "n_filler_each_end": 256, + "bitstream_file": "synth_z_escape_exact.bin", + "bitstream_bytes_stat": 307, + "bitstream_sha256": "43918b9fc2cd685ce619632b83cb00d99b763e7476e2ea08367e1a88d1040c44", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 152, + 152 + ], + "vector_file": "synth_z_escape_exact.gv", + "vector_bytes_stat": 2624, + "expected_decode_sha256": "230e5130f0690bc31122a475f2d04495244ea1c2dcc663c74e9d4182425d9255", + "sym_min": 0, + "sym_max": 9, + "n_skipped": 0, + "roundtrip_exact": true + }, + { + "name": "z_escape_mixed", + "cdf_group": 0, + "n": 256, + "n_filler_each_end": 64, + "bitstream_file": "synth_z_escape_mixed.bin", + "bitstream_bytes_stat": 443, + "bitstream_sha256": "fc3088f9608c0c8e56876d0034d1c8ab45ec34c83dd6491c26981836b4f8075f", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 211, + 229 + ], + "vector_file": "synth_z_escape_mixed.gv", + "vector_bytes_stat": 1088, + "expected_decode_sha256": "a428f456dc7f31ecae1c1fc5fed8517bcd585783f03f3e3423fbf3649745501a", + "sym_min": -72, + "sym_max": 138, + "n_skipped": 0, + "roundtrip_exact": true + }, + { + "name": "z_dense_normal", + "cdf_group": 0, + "n": 10240, + "n_filler_each_end": 4096, + "bitstream_file": "synth_z_dense_normal.bin", + "bitstream_bytes_stat": 3841, + "bitstream_sha256": "0d2c604369c89081df11ac39a2c3218f3e4bcfe208b32fb9a716ea2ea292be8a", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 1891, + 1947 + ], + "vector_file": "synth_z_dense_normal.gv", + "vector_bytes_stat": 41024, + "expected_decode_sha256": "a293328cba494459297083a0e7c465af5f8ee339c701e5d84bef749ace2d9786", + "sym_min": -8, + "sym_max": 7, + "n_skipped": 0, + "roundtrip_exact": true + }, + { + "name": "y_bypass_long", + "cdf_group": 1, + "n": 640, + "n_filler_each_end": 64, + "bitstream_file": "synth_y_bypass_long.bin", + "bitstream_bytes_stat": 2251, + "bitstream_sha256": "faa8b40f9ce682dc10c1cbb6471c684182ed4fa32023ce915513598147f24c99", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 1163, + 1085 + ], + "vector_file": "synth_y_bypass_long.gv", + "vector_bytes_stat": 2624, + "expected_decode_sha256": "f842f136f187644884733ffd83db78647ead2faeec29d2be3d53322159e2589c", + "sym_min": -32000, + "sym_max": 32000, + "n_skipped": 0, + "roundtrip_exact": true + }, + { + "name": "y_skip_indexes", + "cdf_group": 1, + "n": 384, + "n_filler_each_end": 128, + "bitstream_file": "synth_y_skip_indexes.bin", + "bitstream_bytes_stat": 160, + "bitstream_sha256": "4144dd2271f54d7ceab9001dbb43601839a08176d576cad15a3fe02502361be7", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 78, + 79 + ], + "vector_file": "synth_y_skip_indexes.gv", + "vector_bytes_stat": 1600, + "expected_decode_sha256": "8e17e97e31d0e687efec4ae1f6bd3d7ddd80d73f6a3bbf1bcf72072062069ae3", + "sym_min": -1564, + "sym_max": 12345, + "n_skipped": 43, + "roundtrip_exact": true + }, + { + "name": "y_edges_normal", + "cdf_group": 1, + "n": 1280, + "n_filler_each_end": 512, + "bitstream_file": "synth_y_edges_normal.bin", + "bitstream_bytes_stat": 470, + "bitstream_sha256": "ae336f2000c2f6548e897078cf429e85fa1efd6df7973a6257b6495072e5f8ec", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 207, + 260 + ], + "vector_file": "synth_y_edges_normal.gv", + "vector_bytes_stat": 5184, + "expected_decode_sha256": "ff25d3624640925c557155e83d02fd2fec0b25de8f4b3320fcc6c1db2590c6ac", + "sym_min": -1565, + "sym_max": 1564, + "n_skipped": 0, + "roundtrip_exact": true + }, + { + "name": "y_tiny", + "cdf_group": 1, + "n": 130, + "n_filler_each_end": 64, + "bitstream_file": "synth_y_tiny.bin", + "bitstream_bytes_stat": 13, + "bitstream_sha256": "b5df63c5644dc0b7c599a2009de025721c3ff439eed06b427deee1439dc5a84c", + "container_flag": 17, + "container_header_bytes": 2, + "substream_sizes": [ + 6, + 4 + ], + "vector_file": "synth_y_tiny.gv", + "vector_bytes_stat": 584, + "expected_decode_sha256": "e851be60ef0e9dc7488caaf7ba6e35ecccb933ce6775829d0fbd9dc3010d1d49", + "sym_min": -1, + "sym_max": 0, + "n_skipped": 0, + "roundtrip_exact": true + } + ], + "reference_port_selfcheck": { + "ok": 17, + "total": 17 + } +} \ No newline at end of file diff --git a/test/services/golden/vectors/image2.bin b/test/services/golden/vectors/image2.bin new file mode 100644 index 00000000..3506099e Binary files /dev/null and b/test/services/golden/vectors/image2.bin differ diff --git a/test/services/golden/vectors/image2.gv b/test/services/golden/vectors/image2.gv new file mode 100644 index 00000000..a1bac962 Binary files /dev/null and b/test/services/golden/vectors/image2.gv differ diff --git a/test/services/golden/vectors/images.bin b/test/services/golden/vectors/images.bin new file mode 100644 index 00000000..326e1540 Binary files /dev/null and b/test/services/golden/vectors/images.bin differ diff --git a/test/services/golden/vectors/images.gv b/test/services/golden/vectors/images.gv new file mode 100644 index 00000000..e5a5a27b Binary files /dev/null and b/test/services/golden/vectors/images.gv differ diff --git a/test/services/golden/vectors/kodim01.bin b/test/services/golden/vectors/kodim01.bin new file mode 100644 index 00000000..4299cdb4 Binary files /dev/null and b/test/services/golden/vectors/kodim01.bin differ diff --git a/test/services/golden/vectors/kodim01.gv b/test/services/golden/vectors/kodim01.gv new file mode 100644 index 00000000..982d5328 Binary files /dev/null and b/test/services/golden/vectors/kodim01.gv differ diff --git a/test/services/golden/vectors/kodim02.bin b/test/services/golden/vectors/kodim02.bin new file mode 100644 index 00000000..65227408 Binary files /dev/null and b/test/services/golden/vectors/kodim02.bin differ diff --git a/test/services/golden/vectors/kodim02.gv b/test/services/golden/vectors/kodim02.gv new file mode 100644 index 00000000..59fb957f Binary files /dev/null and b/test/services/golden/vectors/kodim02.gv differ diff --git a/test/services/golden/vectors/kodim05.bin b/test/services/golden/vectors/kodim05.bin new file mode 100644 index 00000000..cedccce4 Binary files /dev/null and b/test/services/golden/vectors/kodim05.bin differ diff --git a/test/services/golden/vectors/kodim05.gv b/test/services/golden/vectors/kodim05.gv new file mode 100644 index 00000000..c808964e Binary files /dev/null and b/test/services/golden/vectors/kodim05.gv differ diff --git a/test/services/golden/vectors/kodim08.bin b/test/services/golden/vectors/kodim08.bin new file mode 100644 index 00000000..67a2f493 Binary files /dev/null and b/test/services/golden/vectors/kodim08.bin differ diff --git a/test/services/golden/vectors/kodim08.gv b/test/services/golden/vectors/kodim08.gv new file mode 100644 index 00000000..1f820ad6 Binary files /dev/null and b/test/services/golden/vectors/kodim08.gv differ diff --git a/test/services/golden/vectors/kodim13.bin b/test/services/golden/vectors/kodim13.bin new file mode 100644 index 00000000..732d785f Binary files /dev/null and b/test/services/golden/vectors/kodim13.bin differ diff --git a/test/services/golden/vectors/kodim13.gv b/test/services/golden/vectors/kodim13.gv new file mode 100644 index 00000000..b5fa10c9 Binary files /dev/null and b/test/services/golden/vectors/kodim13.gv differ diff --git a/test/services/golden/vectors/kodim19.bin b/test/services/golden/vectors/kodim19.bin new file mode 100644 index 00000000..2eb725c5 Binary files /dev/null and b/test/services/golden/vectors/kodim19.bin differ diff --git a/test/services/golden/vectors/kodim19.gv b/test/services/golden/vectors/kodim19.gv new file mode 100644 index 00000000..0f0604a1 Binary files /dev/null and b/test/services/golden/vectors/kodim19.gv differ diff --git a/test/services/golden/vectors/kodim23.bin b/test/services/golden/vectors/kodim23.bin new file mode 100644 index 00000000..cd305d98 Binary files /dev/null and b/test/services/golden/vectors/kodim23.bin differ diff --git a/test/services/golden/vectors/kodim23.gv b/test/services/golden/vectors/kodim23.gv new file mode 100644 index 00000000..f68b6464 Binary files /dev/null and b/test/services/golden/vectors/kodim23.gv differ diff --git a/test/services/golden/vectors/kodim24.bin b/test/services/golden/vectors/kodim24.bin new file mode 100644 index 00000000..9c9beb05 Binary files /dev/null and b/test/services/golden/vectors/kodim24.bin differ diff --git a/test/services/golden/vectors/kodim24.gv b/test/services/golden/vectors/kodim24.gv new file mode 100644 index 00000000..49b43683 Binary files /dev/null and b/test/services/golden/vectors/kodim24.gv differ diff --git a/test/services/golden/vectors/synth_y_bypass_long.bin b/test/services/golden/vectors/synth_y_bypass_long.bin new file mode 100644 index 00000000..f955f8d3 --- /dev/null +++ b/test/services/golden/vectors/synth_y_bypass_long.bin @@ -0,0 +1,3 @@ +;a/۟p=a/۟p=a/۟p=a/۟p=a~p;a~p;a~p;a~p;a~p;a/~p9a/~p9a/a/~p9a}{p7a}{p7a/}[p5a/}?[p5a+|p ?7/ _a_/ _a_.pf/ao{>,p__/ +_a_ {ya/ [_qa_/_aa_/w>rpvpGUa/vbpe?EaJ;+9gWs>pFwor&p@?} }`9o/~_`_ /~_`_ +/[~_q`_ji~I`2/ۼ1~_`_ywm﬛ﭛl_n['}KoGE{˒ےmRmj,_^_u F_BmQwA_x<l^__j]B/A{E{!]%]/*kz\jﻚy["/&jU_B/Ax_!Z_:hssߑg{aa3\;tLVj=^dgwTTfo﬘bGNWNK_o}4`v_u_/[o[=?ﬗ^ǶmOOVR\[qGW1M5M&kfy_&Z'Z_bcV;Z'7mG[(_KLo1__!6?_?b<-^ @ @{ +)+g >^ 9LSOvOjoO@~@OݟS0 \ No newline at end of file diff --git a/test/services/golden/vectors/synth_y_bypass_long.gv b/test/services/golden/vectors/synth_y_bypass_long.gv new file mode 100644 index 00000000..1043185c Binary files /dev/null and b/test/services/golden/vectors/synth_y_bypass_long.gv differ diff --git a/test/services/golden/vectors/synth_y_edges_normal.bin b/test/services/golden/vectors/synth_y_edges_normal.bin new file mode 100644 index 00000000..4e89c6fe Binary files /dev/null and b/test/services/golden/vectors/synth_y_edges_normal.bin differ diff --git a/test/services/golden/vectors/synth_y_edges_normal.gv b/test/services/golden/vectors/synth_y_edges_normal.gv new file mode 100644 index 00000000..03aa5085 Binary files /dev/null and b/test/services/golden/vectors/synth_y_edges_normal.gv differ diff --git a/test/services/golden/vectors/synth_y_skip_indexes.bin b/test/services/golden/vectors/synth_y_skip_indexes.bin new file mode 100644 index 00000000..6870de8c Binary files /dev/null and b/test/services/golden/vectors/synth_y_skip_indexes.bin differ diff --git a/test/services/golden/vectors/synth_y_skip_indexes.gv b/test/services/golden/vectors/synth_y_skip_indexes.gv new file mode 100644 index 00000000..b378c92d Binary files /dev/null and b/test/services/golden/vectors/synth_y_skip_indexes.gv differ diff --git a/test/services/golden/vectors/synth_y_tiny.bin b/test/services/golden/vectors/synth_y_tiny.bin new file mode 100644 index 00000000..363e394d Binary files /dev/null and b/test/services/golden/vectors/synth_y_tiny.bin differ diff --git a/test/services/golden/vectors/synth_y_tiny.gv b/test/services/golden/vectors/synth_y_tiny.gv new file mode 100644 index 00000000..6f732429 Binary files /dev/null and b/test/services/golden/vectors/synth_y_tiny.gv differ diff --git a/test/services/golden/vectors/synth_z_dense_normal.bin b/test/services/golden/vectors/synth_z_dense_normal.bin new file mode 100644 index 00000000..328fa5b2 Binary files /dev/null and b/test/services/golden/vectors/synth_z_dense_normal.bin differ diff --git a/test/services/golden/vectors/synth_z_dense_normal.gv b/test/services/golden/vectors/synth_z_dense_normal.gv new file mode 100644 index 00000000..c44ecd0b Binary files /dev/null and b/test/services/golden/vectors/synth_z_dense_normal.gv differ diff --git a/test/services/golden/vectors/synth_z_escape_exact.bin b/test/services/golden/vectors/synth_z_escape_exact.bin new file mode 100644 index 00000000..7edd0f6d Binary files /dev/null and b/test/services/golden/vectors/synth_z_escape_exact.bin differ diff --git a/test/services/golden/vectors/synth_z_escape_exact.gv b/test/services/golden/vectors/synth_z_escape_exact.gv new file mode 100644 index 00000000..e0f6db32 Binary files /dev/null and b/test/services/golden/vectors/synth_z_escape_exact.gv differ diff --git a/test/services/golden/vectors/synth_z_escape_mixed.bin b/test/services/golden/vectors/synth_z_escape_mixed.bin new file mode 100644 index 00000000..54fbf5c4 Binary files /dev/null and b/test/services/golden/vectors/synth_z_escape_mixed.bin differ diff --git a/test/services/golden/vectors/synth_z_escape_mixed.gv b/test/services/golden/vectors/synth_z_escape_mixed.gv new file mode 100644 index 00000000..0b0ba509 Binary files /dev/null and b/test/services/golden/vectors/synth_z_escape_mixed.gv differ diff --git a/test/services/image_codec_boot_payload_test.dart b/test/services/image_codec_boot_payload_test.dart new file mode 100644 index 00000000..d88ce3d5 --- /dev/null +++ b/test/services/image_codec_boot_payload_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/services/image_codec_session_io.dart'; + +/// Connection tests for the codec worker's boot payload. +/// +/// Two bugs in this feature had the same shape and both survived a green suite: +/// `imageCodecRansCoderBuilder` was declared, documented and consumed but never +/// assigned; and `entropyDecodeGraphPath` was resolved on the main isolate but +/// never put in the list handed to the worker, so every decode threw +/// [ImageCodecBundleIncomplete] while `canDecode` cheerfully reported true. +/// +/// Neither was a broken component. Both were missing *connections*, and unit +/// tests that exercise each side in isolation cannot see them. These tests +/// assert the payload itself: what spawn() sends is exactly what the worker +/// needs to rebuild a bundle that can decode. +void main() { + ImageCodecBundle fiveAssetBundle() => const ImageCodecBundle( + decoderGraphPath: '/models/aeic_decoder_qdq_conv_pct.onnx', + entropyGraphPath: '/models/aeic_entropy_side_fp32_op17.onnx', + entropyDecodeGraphPath: '/models/aeic_entropy_decode_fp32_op17.onnx', + tablesPath: '/models/aeic_cdf_ft32.bin', + ratePoint: AeicRatePoint.ft32, + ); + + group('codec worker boot payload', () { + test('a five-asset bundle survives the round trip and can still decode', () { + final sent = fiveAssetBundle(); + expect(sent.supportsDecode, isTrue, reason: 'precondition'); + + final rebuilt = debugBundleFromBootPayload( + debugBootPayloadFor(sent), + ); + + expect(rebuilt.decoderGraphPath, sent.decoderGraphPath); + expect(rebuilt.entropyGraphPath, sent.entropyGraphPath); + expect(rebuilt.tablesPath, sent.tablesPath); + expect(rebuilt.ratePoint, sent.ratePoint); + // The one that was missing. Without it the worker builds a bundle whose + // supportsDecode is false and every decode throws, on a correct install. + expect(rebuilt.entropyDecodeGraphPath, sent.entropyDecodeGraphPath); + expect(rebuilt.supportsDecode, isTrue); + }); + + test('no field is silently reindexed by the positional layout', () { + // The payload is a positional List. Inserting a slot anywhere but the end + // shifts every field after it, and the casts are permissive enough that + // tablesPath could arrive as the rate point without throwing. Pin each + // slot to its meaning so a future insert fails here rather than in the + // field, where it would read as a mysterious wrong-model error. + final payload = debugBootPayloadFor(fiveAssetBundle()); + expect(payload[1], '/models/aeic_decoder_qdq_conv_pct.onnx'); + expect(payload[2], '/models/aeic_entropy_side_fp32_op17.onnx'); + expect(payload[3], '/models/aeic_cdf_ft32.bin'); + expect(payload[4], AeicRatePoint.ft32.wireValue); + expect(payload[6], '/models/aeic_entropy_decode_fp32_op17.onnx'); + }); + + test('a send-only bundle rebuilds as send-only rather than half-decoding', + () { + const sendOnly = ImageCodecBundle( + decoderGraphPath: '/models/decoder.onnx', + entropyGraphPath: '/models/entropy.onnx', + tablesPath: '/models/tables.bin', + ratePoint: AeicRatePoint.ft32, + ); + final rebuilt = debugBundleFromBootPayload( + debugBootPayloadFor(sendOnly), + ); + expect(rebuilt.entropyDecodeGraphPath, isNull); + expect(rebuilt.supportsDecode, isFalse); + }); + + test('a short payload does not crash the worker', () { + // An older sender, or a truncated message, must degrade to "cannot + // decode" rather than throwing a RangeError inside the isolate where the + // failure would surface as an opaque spawn error. + final short = debugBootPayloadFor(fiveAssetBundle()).sublist(0, 6); + final rebuilt = debugBundleFromBootPayload(short); + expect(rebuilt.entropyDecodeGraphPath, isNull); + expect(rebuilt.supportsDecode, isFalse); + }); + }); +} diff --git a/test/services/image_codec_download_test.dart b/test/services/image_codec_download_test.dart new file mode 100644 index 00000000..17e365e4 --- /dev/null +++ b/test/services/image_codec_download_test.dart @@ -0,0 +1,723 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; +import 'package:meshcore_open/services/image_codec_file_store.dart'; +import 'package:meshcore_open/services/image_codec_service.dart'; +import 'package:meshcore_open/services/image_codec_settings_store.dart'; + +/// Real file store, redirected at a temp directory. +/// +/// Subclassed rather than faked on purpose: the resume logic's whole premise is +/// that a partial file's *length on disk* is its progress marker, so a test that +/// mocked the filesystem would be testing the mock. Only the one method that +/// needs `path_provider` (unavailable in a unit test) is overridden. +class _TempFileStore extends ImageCodecFileStore { + final String root; + + _TempFileStore(this.root); + + @override + Future modelDirectoryPath() async => root; +} + +/// Deterministic pseudo-random bytes, so a sliced Range response can be checked +/// byte-for-byte against the source. +Uint8List _body(int length, [int seed = 7]) { + final bytes = Uint8List(length); + var state = seed | 1; + for (var i = 0; i < length; i++) { + state = (state * 1103515245 + 12345) & 0x7FFFFFFF; + bytes[i] = (state >> 16) & 0xFF; + } + return bytes; +} + +String _sha256(List bytes) => crypto.sha256.convert(bytes).toString(); + +/// A record of every range the client asked for, as `'start-end'`. +class _Log { + final List ranges = []; + int headCount = 0; +} + +/// Serves [assets] (by last path segment) over Range requests. +/// +/// [failAtOffset] makes exactly one range request die half-way through its body, +/// which is what an interrupted 872 MB transfer looks like from Dart's side. +http.Client Function() _server( + Map assets, + _Log log, { + int? failAtOffset, + bool acceptRanges = true, +}) { + return () => MockClient.streaming((request, _) async { + final name = request.url.pathSegments.last; + final data = assets[name]; + if (data == null) { + return http.StreamedResponse(const Stream>.empty(), 404); + } + if (request.method == 'HEAD') { + log.headCount++; + return http.StreamedResponse( + const Stream>.empty(), + 200, + contentLength: data.length, + headers: acceptRanges ? {'accept-ranges': 'bytes'} : const {}, + ); + } + final range = request.headers['Range']; + if (range == null) { + return http.StreamedResponse( + Stream>.value(data), + 200, + contentLength: data.length, + ); + } + final match = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(range)!; + final start = int.parse(match.group(1)!); + final end = int.parse(match.group(2)!); + log.ranges.add('$start-$end'); + final slice = data.sublist(start, end + 1); + + Stream> stream() async* { + if (failAtOffset == start) { + yield slice.sublist(0, slice.length ~/ 2); + throw const SocketException('connection reset by peer'); + } + // Several chunks, so a mid-stream failure is a realistic partial write. + const pieces = 4; + final step = (slice.length / pieces).ceil(); + for (var i = 0; i < slice.length; i += step) { + yield slice.sublist(i, (i + step).clamp(0, slice.length)); + } + } + + return http.StreamedResponse( + stream(), + 206, + contentLength: slice.length, + headers: {'content-range': 'bytes $start-$end/${data.length}'}, + ); + }); +} + +void main() { + late Directory tempDir; + late _TempFileStore store; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('image_codec_dl'); + store = _TempFileStore(tempDir.path); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + ImageCodecService serviceWith(http.Client Function() clientFactory) { + return ImageCodecService( + AppSettingsService(), + fileStore: store, + settingsStore: InMemoryImageCodecSettingsStore(), + clientFactory: clientFactory, + ); + } + + // 12 MiB clears the 10 MB threshold, so this asset takes the 8-way ranged + // path; the three small ones take the plain GET path. Both are exercised. + // The shape mirrors the real bundle: a small decoder graph, a huge weights + // sibling, a mid-size entropy graph and a tiny table file. + final large = _body(12 * 1024 * 1024, 3); + final small = _body(4096, 11); + final entropy = _body(65536, 23); + final entropyDecode = _body(32768, 31); + final tables = _body(2048, 29); + + /// The five-role spec. Digests default to empty (verification skipped), which + /// is the shipping state until the weights are published. + ImageCodecModelSpec spec({ + String? largeDigest, + String? smallDigest, + String? entropyDigest, + String? entropyDecodeDigest, + String? tablesDigest, + }) { + return ImageCodecModelSpec( + id: 'test-model', + label: 'Test model', + ratePoint: AeicRatePoint.ft32, + assets: [ + ImageCodecModelAsset( + role: ImageCodecAssetRole.decoderGraph, + fileName: 'model.onnx', + sourceUrl: 'https://example.invalid/repo/model.onnx', + sizeBytes: small.length, + sha256: smallDigest ?? '', + ), + ImageCodecModelAsset( + role: ImageCodecAssetRole.decoderWeights, + fileName: 'model.onnx.data', + sourceUrl: 'https://example.invalid/repo/model.onnx.data', + sizeBytes: large.length, + sha256: largeDigest ?? '', + ), + ImageCodecModelAsset( + role: ImageCodecAssetRole.entropyGraph, + fileName: 'entropy.onnx', + sourceUrl: 'https://example.invalid/repo/entropy.onnx', + sizeBytes: entropy.length, + sha256: entropyDigest ?? '', + ), + ImageCodecModelAsset( + role: ImageCodecAssetRole.entropyDecodeGraph, + fileName: 'entropy_decode.onnx', + sourceUrl: 'https://example.invalid/repo/entropy_decode.onnx', + sizeBytes: entropyDecode.length, + sha256: entropyDecodeDigest ?? '', + ), + ImageCodecModelAsset( + role: ImageCodecAssetRole.cdfTables, + fileName: 'cdf.bin', + sourceUrl: 'https://example.invalid/repo/cdf.bin', + sizeBytes: tables.length, + sha256: tablesDigest ?? '', + ), + ], + ); + } + + ImageCodecModelSpec verifiedSpec() => spec( + smallDigest: _sha256(small), + largeDigest: _sha256(large), + entropyDigest: _sha256(entropy), + entropyDecodeDigest: _sha256(entropyDecode), + tablesDigest: _sha256(tables), + ); + + final serverAssets = { + 'model.onnx': small, + 'model.onnx.data': large, + 'entropy.onnx': entropy, + 'entropy_decode.onnx': entropyDecode, + 'cdf.bin': tables, + }; + + group('downloadPresetModel', () { + test('fetches all five assets of the bundle and verifies each', () async { + final log = _Log(); + final service = serviceWith(_server(serverAssets, log)); + addTearDown(service.dispose); + + final record = await service.downloadPresetModel(verifiedSpec()); + + // The record points at the DECODER GRAPH, not the weights and not + // whichever asset happened to be first: that is the path ONNX Runtime is + // handed. + expect(record.name, 'model.onnx'); + expect(record.localPath, '${tempDir.path}/model.onnx'); + // ...but the recorded size is the whole bundle, because that is what the + // user gave up on their device. + expect( + record.fileSizeBytes, + small.length + + large.length + + entropy.length + + entropyDecode.length + + tables.length, + ); + expect(record.assetFileNames, [ + 'model.onnx', + 'model.onnx.data', + 'entropy.onnx', + 'entropy_decode.onnx', + 'cdf.bin', + ]); + expect(record.bundleVersion, kImageCodecBundleVersion); + + final graph = File('${tempDir.path}/model.onnx'); + final weights = File('${tempDir.path}/model.onnx.data'); + expect(graph.existsSync(), isTrue); + expect(weights.existsSync(), isTrue); + expect(await graph.readAsBytes(), small); + expect(await weights.length(), large.length); + expect(_sha256(await weights.readAsBytes()), _sha256(large)); + expect( + await File('${tempDir.path}/entropy.onnx').readAsBytes(), + entropy, + ); + // Two different entropy exports land side by side; neither may overwrite + // or be mistaken for the other. + expect( + await File('${tempDir.path}/entropy_decode.onnx').readAsBytes(), + entropyDecode, + ); + expect(entropyDecode, isNot(entropy)); + expect(await File('${tempDir.path}/cdf.bin').readAsBytes(), tables); + + // The external-weights sibling MUST keep its exact name or the graph's + // relative reference will not resolve. + expect(weights.uri.pathSegments.last, 'model.onnx.data'); + + // Resume state is swept once an asset verifies. + final leftovers = tempDir + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.startsWith('.')) + .toList(); + expect(leftovers, isEmpty); + + expect(service.selectedModel?.id, 'test-model'); + }); + + test('progress is one bar across the set, not four', () async { + final log = _Log(); + final service = serviceWith(_server(serverAssets, log)); + addTearDown(service.dispose); + + final progress = []; + final names = {}; + service.addListener(() { + final value = service.downloadProgress; + if (value != null) progress.add(value); + final name = service.downloadFileName; + if (name != null) names.add(name); + }); + + await service.downloadPresetModel(verifiedSpec()); + + expect(progress, isNotEmpty); + // Monotonic: a per-file bar would snap back to 0 three times. + for (var i = 1; i < progress.length; i++) { + expect( + progress[i], + greaterThanOrEqualTo(progress[i - 1]), + reason: 'progress went backwards at $i', + ); + } + expect(progress.last, closeTo(1.0, 0.001)); + // Every asset was named while it was in flight, so the UI can say which + // of the four files a 900 MB transfer is on. + expect( + names, + containsAll(['model.onnx', 'model.onnx.data', 'cdf.bin']), + ); + }); + + test('refuses a spec that is missing a bundle role', () async { + final service = serviceWith(_server(serverAssets, _Log())); + addTearDown(service.dispose); + + // Decoder-only: it would install a codec that can render a latent and + // nothing else, which is exactly the state this work removes. + final full = spec(); + await expectLater( + service.downloadPresetModel( + ImageCodecModelSpec( + id: 'decoder-only', + label: 'Decoder only', + assets: full.assets.take(2).toList(), + ), + ), + throwsA(isA()), + ); + expect(tempDir.listSync(), isEmpty); + }); + + test('discardPartialDownload sweeps every asset of the bundle', () async { + final service = serviceWith(_server(serverAssets, _Log())); + addTearDown(service.dispose); + final target = spec(); + for (final asset in target.assets) { + await File( + await store.chunkFilePath('${asset.fileName}.99', 0), + ).writeAsBytes(const [1, 2, 3]); + } + final other = File(await store.chunkFilePath('unrelated.onnx.99', 0)); + await other.writeAsBytes(const [1]); + + await service.discardPartialDownload(target); + + final leftovers = tempDir + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .toList(); + expect(leftovers, ['.unrelated.onnx.99_chunk_0']); + }); + + test('refuses a spec whose URLs are placeholders', () async { + final service = serviceWith(_server(serverAssets, _Log())); + addTearDown(service.dispose); + + await expectLater( + service.downloadPresetModel( + ImageCodecModelSpec( + id: 'placeholder', + label: 'Placeholder', + urlsArePlaceholders: true, + assets: spec().assets, + ), + ), + throwsA(isA()), + ); + expect(tempDir.listSync(), isEmpty); + }); + + test('the shipped preset points at published, verifiable assets', () { + // The weights are published, so the old "still a placeholder" guard is + // inverted: what matters now is that nothing ships half-wired. A real URL + // with an empty digest is worse than a placeholder, because the download + // succeeds and the integrity check silently passes. + expect(imageCodecPresetModels, hasLength(1)); + for (final preset in imageCodecPresetModels) { + expect(preset.urlsArePlaceholders, isFalse); + expect(preset.assets, isNotEmpty); + for (final asset in preset.assets) { + expect( + asset.sourceUrl, + startsWith('https://huggingface.co/'), + reason: asset.fileName, + ); + expect( + asset.sourceUrl, + contains(asset.fileName), + reason: '${asset.fileName} url must name its own file', + ); + expect(asset.sizeBytes, greaterThan(0), reason: asset.fileName); + expect( + asset.sha256, + matches(RegExp(r'^[0-9a-f]{64}$')), + reason: '${asset.fileName} needs a real digest', + ); + } + // Five assets, one bundle: decoder graph + weights, both entropy + // graphs, and the CDF tables. + expect(preset.assets, hasLength(5)); + } + }); + }); + + group('resume', () { + test('a broken transfer resumes from the bytes already on disk', () async { + final chunkSize = (large.length / 8).ceil(); + final victimStart = chunkSize * 3; + + final firstLog = _Log(); + final first = serviceWith( + _server(serverAssets, firstLog, failAtOffset: victimStart), + ); + addTearDown(first.dispose); + + await expectLater( + first.downloadPresetModel(spec()), + throwsA(isA()), + ); + + // Seven chunks landed whole, one is half-written, and none were deleted. + final partials = tempDir + .listSync() + .whereType() + .where((f) => f.uri.pathSegments.last.startsWith('.')) + .toList(); + expect(partials, hasLength(8)); + final victimPath = partials.firstWhere( + (f) => f.uri.pathSegments.last.endsWith('_chunk_3'), + ); + final resumeFrom = await victimPath.length(); + expect(resumeFrom, greaterThan(0)); + expect(resumeFrom, lessThan(chunkSize)); + expect(File('${tempDir.path}/model.onnx.data').existsSync(), isFalse); + + final secondLog = _Log(); + final second = serviceWith(_server(serverAssets, secondLog)); + addTearDown(second.dispose); + await second.downloadPresetModel(verifiedSpec()); + + // Exactly one range was re-requested, and it started where the partial + // file ended rather than at the chunk boundary. + expect(secondLog.ranges, hasLength(1)); + expect( + secondLog.ranges.single, + startsWith('${victimStart + resumeFrom}-'), + ); + + final weights = File('${tempDir.path}/model.onnx.data'); + expect(await weights.length(), large.length); + expect(_sha256(await weights.readAsBytes()), _sha256(large)); + }); + + test('an already-complete verified asset is not re-fetched', () async { + final log = _Log(); + final service = serviceWith(_server(serverAssets, log)); + addTearDown(service.dispose); + final digested = verifiedSpec(); + + await service.downloadPresetModel(digested); + final rangesAfterFirst = log.ranges.length; + expect(rangesAfterFirst, greaterThan(0)); + + await service.downloadPresetModel(digested); + expect(log.ranges.length, rangesAfterFirst, reason: 'no re-download'); + }); + + test('resume state does not survive a change in upstream length', () async { + // Chunk keys embed the total size, so offsets computed for one length can + // never be spliced onto a file of another length. + final a = await store.chunkFilePath('model.onnx.data.1000', 3); + final b = await store.chunkFilePath('model.onnx.data.2000', 3); + expect(a, isNot(b)); + }); + }); + + group('integrity', () { + test('a wrong digest fails loudly and removes the file', () async { + final service = serviceWith(_server(serverAssets, _Log())); + addTearDown(service.dispose); + + await expectLater( + service.downloadPresetModel( + spec(smallDigest: _sha256(utf8.encode('not the model'))), + ), + throwsA(isA()), + ); + expect(File('${tempDir.path}/model.onnx').existsSync(), isFalse); + expect(service.selectedModel, isNull); + }); + + test('sha256OfFile streams the same digest as an in-memory hash', () async { + final path = '${tempDir.path}/blob.bin'; + await File(path).writeAsBytes(large); + expect(await store.sha256OfFile(path), _sha256(large)); + }); + + test('a missing digest is skipped rather than treated as a match', () { + const withDigest = ImageCodecModelAsset( + role: ImageCodecAssetRole.decoderGraph, + fileName: 'a', + sourceUrl: 'https://example.invalid/a', + sizeBytes: 1, + sha256: + '0000000000000000000000000000000000000000000000000000000000000000', + ); + const without = ImageCodecModelAsset( + role: ImageCodecAssetRole.cdfTables, + fileName: 'b', + sourceUrl: 'https://example.invalid/b', + sizeBytes: 1, + ); + expect(withDigest.hasChecksum, isTrue); + expect(without.hasChecksum, isFalse); + }); + }); + + group('scanDownloadedModels', () { + test('preserves chunk files but reaps other hidden junk', () async { + final chunk = File(await store.chunkFilePath('model.onnx.data.99', 2)); + await chunk.writeAsBytes(const [1, 2, 3]); + final junk = File('${tempDir.path}/.DS_Store'); + await junk.writeAsBytes(const [0]); + await File('${tempDir.path}/model.onnx').writeAsBytes(small); + + final found = await store.scanDownloadedModels(); + + expect(found.map((m) => m.name), ['model.onnx']); + expect(chunk.existsSync(), isTrue, reason: 'resume state must survive'); + expect(junk.existsSync(), isFalse); + }); + + test('deletePartialDownloads sweeps only the named model', () async { + final mine = File(await store.chunkFilePath('model.onnx.data.99', 0)); + final other = File(await store.chunkFilePath('other.onnx.99', 0)); + await mine.writeAsBytes(const [1]); + await other.writeAsBytes(const [1]); + + await store.deletePartialDownloads('model.onnx.data'); + + expect(mine.existsSync(), isFalse); + expect(other.existsSync(), isTrue); + }); + }); + + group('non-ranged servers', () { + test('fall back to a single GET', () async { + final log = _Log(); + final service = serviceWith( + _server(serverAssets, log, acceptRanges: false), + ); + addTearDown(service.dispose); + + await service.downloadPresetModel(verifiedSpec()); + + expect(log.ranges, isEmpty); + expect( + await File('${tempDir.path}/model.onnx.data').length(), + large.length, + ); + }); + }); + + group('installedBundle', () { + test( + 'a fresh install resolves the decoder, entropy and table paths', + () async { + final service = serviceWith(_server(serverAssets, _Log())); + addTearDown(service.dispose); + + await service.downloadPresetModel(verifiedSpec()); + + final bundle = service.installedBundle; + expect(bundle, isNotNull); + expect(bundle!.decoderGraphPath, '${tempDir.path}/model.onnx'); + expect(bundle.entropyGraphPath, '${tempDir.path}/entropy.onnx'); + // Resolved by ROLE. Both entropy files end in `.onnx`, so a + // position- or extension-based guess would be a coin flip, and handing + // the decode-side graph to the encoder fails at the first run. + expect( + bundle.entropyDecodeGraphPath, + '${tempDir.path}/entropy_decode.onnx', + ); + expect(bundle.tablesPath, '${tempDir.path}/cdf.bin'); + expect(bundle.isComplete, isTrue); + expect(bundle.supportsDecode, isTrue); + expect(service.needsModelUpgrade, isFalse); + expect(service.needsModelDownload, isFalse); + }, + ); + + test('a bundle-version-1 install can send but not receive', () async { + // The record the previous release wrote: four assets, no decode-side + // graph. Encoding still works, decoding does not, and the remedy is a + // re-download rather than "your device cannot do this". + final v1 = ImageCodecModelRecord( + id: 'test-model', + name: 'model.onnx', + sourceUrl: 'https://example.invalid/repo/model.onnx', + localPath: '${tempDir.path}/model.onnx', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000), + fileSizeBytes: 1, + assetFileNames: const [ + 'model.onnx', + 'model.onnx.data', + 'entropy.onnx', + 'cdf.bin', + ], + bundleVersion: 1, + ); + final service = ImageCodecService( + AppSettingsService(), + fileStore: store, + settingsStore: InMemoryImageCodecSettingsStore( + ImageCodecPreferences( + enabled: true, + selectedModelId: v1.id, + downloadedModels: [v1], + ), + ), + clientFactory: _server(serverAssets, _Log()), + ); + addTearDown(service.dispose); + + final bundle = service.installedBundle; + expect(bundle, isNotNull); + expect(bundle!.entropyGraphPath, '${tempDir.path}/entropy.onnx'); + // No spec asset name is present in the record, and the heuristic must NOT + // invent one: a filename that was never downloaded resolves to an opaque + // ORT failure instead of a download prompt. + expect(bundle.entropyDecodeGraphPath, isNull); + expect(bundle.isComplete, isTrue); + expect(bundle.supportsDecode, isFalse); + expect(service.needsModelUpgrade, isTrue); + expect(service.needsModelDownload, isFalse); + expect(service.canDecode, isFalse); + expect(service.statusReason, isNotNull); + }); + + test('a pre-bundle install is an upgrade, not a broken build', () { + // The decoder-only record the shipped build wrote: no asset list, no + // bundle version. It must stay loadable, report an incomplete bundle, + // and ask for a download rather than declaring the device incapable. + final legacy = ImageCodecModelRecord( + id: 'aeic-se-decoder-qdq-conv-pct-novae', + name: 'aeic_decoder_qdq_conv_pct_novae.onnx', + sourceUrl: 'https://example.invalid/x.onnx', + localPath: '${tempDir.path}/aeic_decoder_qdq_conv_pct_novae.onnx', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(1730000000000), + fileSizeBytes: 2909610, + ); + final service = ImageCodecService( + AppSettingsService(), + fileStore: store, + settingsStore: InMemoryImageCodecSettingsStore( + ImageCodecPreferences( + enabled: true, + selectedModelId: legacy.id, + downloadedModels: [legacy], + ), + ), + clientFactory: _server(serverAssets, _Log()), + ); + addTearDown(service.dispose); + + expect(service.needsModelDownload, isFalse, reason: 'a model IS present'); + expect(service.needsModelUpgrade, isTrue); + final bundle = service.installedBundle; + expect(bundle, isNotNull); + expect(bundle!.decoderGraphPath, legacy.localPath); + expect(bundle.entropyGraphPath, isNull); + expect(bundle.entropyDecodeGraphPath, isNull); + expect(bundle.tablesPath, isNull); + expect(bundle.isComplete, isFalse); + expect(bundle.supportsDecode, isFalse); + expect(service.canEncode, isFalse); + expect(service.canDecode, isFalse); + // An incomplete install is a download away, so the user always gets a + // sentence explaining what to do. + expect(service.statusReason, isNotNull); + }); + + test('nothing installed means no bundle and a download prompt', () { + final service = ImageCodecService( + AppSettingsService(), + fileStore: store, + settingsStore: InMemoryImageCodecSettingsStore( + const ImageCodecPreferences(enabled: true), + ), + clientFactory: _server(serverAssets, _Log()), + ); + addTearDown(service.dispose); + + expect(service.installedBundle, isNull); + expect(service.needsModelDownload, isTrue); + expect(service.needsModelUpgrade, isFalse); + expect(service.canEncode, isFalse); + }); + + test('statusReason is a superset of unavailableReason', () { + final service = serviceWith(_server(serverAssets, _Log())); + addTearDown(service.dispose); + + // While kImageCodecBitstreamPathAvailable is false this is the build + // sentence; when the gate flips, the remaining branches (switched off, + // not downloaded, needs upgrade) take over. Either way it is non-empty + // whenever the codec is not ready, which is the contract the compose + // sheet's banner depends on. + final status = service.statusReason; + expect(status, isNotNull); + expect(status!.trim(), isNotEmpty); + final permanent = service.unavailableReason; + if (permanent != null) { + expect(status, permanent); + } + }); + }); +} diff --git a/test/services/image_codec_e2e_test.dart b/test/services/image_codec_e2e_test.dart new file mode 100644 index 00000000..78258a10 --- /dev/null +++ b/test/services/image_codec_e2e_test.dart @@ -0,0 +1,407 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/entropy_tables.dart'; +import 'package:meshcore_open/services/image_codec_backend.dart'; +import 'package:meshcore_open/services/image_codec_entropy.dart'; + +/// End-to-end cross-language conformance for the AEIC entropy path. +/// +/// This is deliberately **not** a Dart-encodes-then-Dart-decodes round trip. +/// That shape passes happily with a completely wrong wire format — a swapped +/// mask permutation, a reversed squeeze, an off-by-one in `my_build_indexes` — +/// because both halves make the same mistake. Every assertion here compares +/// Dart against bytes and tensors that Python/ORT/C++ produced: +/// +/// ENCODE: recorded encode-graph outputs -> real Dart four-stage loop -> +/// real Dart rANS ==> byte-identical to the recorded C++ bitstream. +/// DECODE: recorded C++ bitstream -> real Dart rANS -> real Dart four-stage +/// loop (replaying the recorded decode-side network calls) +/// ==> y_hat exactly equal, element for element, to the recorded one. +/// +/// The only thing faked is [AeicEntropyNetwork]: the neural half is replayed +/// from `.aeicrec` recordings made by `aic/exp/record_entropy_io.py`. The fake +/// asserts on its *inputs* as well as returning outputs — in particular the +/// `base` tensor handed to each decode stage must match the recorded one bit +/// for bit, which is what localises a wrong mask / mergeContext / squeeze to +/// the stage that broke instead of to a garbled final image. +void main() { + final Directory goldenDir = _resolveGoldenDir(); + final Directory e2eDir = Directory('${goldenDir.path}/e2e'); + final EntropyTables tables = EntropyTables.parse( + File('${goldenDir.path}/aeic_cdf_ft32.bin').readAsBytesSync(), + ); + final AeicRansCoderFactory coders = AeicRansCoders(tables); + final Map manifest = + jsonDecode(File('${e2eDir.path}/manifest.json').readAsStringSync()) + as Map; + final List> files = (manifest['files'] as List) + .cast>(); + + test('recording corpus is present and unmodified', () { + expect(manifest['format'], 'aeic-entropy-e2e-recording'); + expect(manifest['version'], 1); + expect(manifest['checkpoint'], 'AEIC_SE_ft32.pkl'); + expect(manifest['size'], 512); + expect(files.length, 5); + for (final Map rec in files) { + final File f = File('${e2eDir.path}/${rec['file']}'); + expect(f.existsSync(), isTrue, reason: '${rec['file']} missing'); + final Uint8List raw = f.readAsBytesSync(); + expect(raw.length, rec['bytes'], reason: '${rec['file']} size'); + expect( + sha256.convert(raw).toString(), + rec['sha256'], + reason: '${rec['file']} sha256', + ); + } + }); + + for (final Map rec in files) { + final String name = rec['file'] as String; + group(name, () { + late _Recording r; + late AeicEntropyGeometry geometry; + late AeicMaskSet masks; + + setUpAll(() { + r = _Recording.load('${e2eDir.path}/$name'); + geometry = AeicEntropyGeometry.forResolution( + r.meta['size'] as int, + yChannels: (r.meta['y_shape'] as List)[1] as int, + ); + masks = AeicMaskSet(geometry); + }); + + test('recording shape matches the geometry the codec derives', () { + expect(r.meta['checkpoint'], 'AEIC_SE_ft32.pkl'); + expect(r.meta['z_cdf_group'], kAeicZCdfGroup); + expect(r.meta['y_cdf_group'], kAeicYCdfGroup); + expect(r.meta['byte_order'], 'little'); + expect(r.f32('enc/z_q').length, geometry.zElements); + expect(r.f32('enc/y_hat').length, geometry.yElements); + expect(r.f32('dec/y_hat').length, geometry.yElements); + expect(r.u8('enc/bitstream').length, r.meta['bitstream_bytes']); + expect(r.calls.length, 5); + expect(r.calls[0]['kind'], 'hyper_synthesis'); + for (var i = 0; i < 4; i++) { + expect(r.calls[i + 1]['kind'], 'stage'); + expect(r.calls[i + 1]['stage'], i); + } + }); + + // The pieces the Dart entropy layer computes on its own between the + // graph and the coder. Checked against Python directly so a divergence + // here is attributed to squeeze / my_build_indexes rather than to rANS. + test('symbols and indexes match the recorded integer arrays', () { + _expectSameInts( + aeicToSymbols(r.f32('enc/z_q')), + r.i16('enc/z_symbols'), + 'z symbols', + ); + _expectSameInts( + aeicZIndexes(geometry), + r.i16('enc/z_indexes'), + 'z indexes', + ); + for (var s = 0; s < 4; s++) { + _expectSameInts( + aeicToSymbols(masks.squeeze(r.f32('enc/yq$s'))), + r.i16('enc/symbols$s'), + 'stage $s symbols', + ); + _expectSameInts( + aeicBuildIndexes(masks.squeeze(r.f32('enc/sc$s'))), + r.i16('enc/indexes$s'), + 'stage $s indexes', + ); + } + }); + + test('ENCODE: Dart bitstream is byte-identical to the C++ bitstream', + () async { + final _ReplayNetwork network = _ReplayNetwork(r); + final AeicEntropyCodec codec = AeicEntropyCodec( + geometry: geometry, + network: network, + coders: coders, + ); + final Uint8List got = await codec.encode( + Uint8List(geometry.resolution * geometry.resolution * 3), + ); + expect(network.encodeCalls, 1); + _expectSameBytes(got, r.u8('enc/bitstream'), name); + expect( + sha256.convert(got).toString(), + r.meta['bitstream_sha256'], + reason: '$name: bitstream sha256', + ); + }); + + test('DECODE: y_hat from the C++ bitstream is exactly the recorded y_hat', + () async { + final _ReplayNetwork network = _ReplayNetwork(r); + final AeicEntropyCodec codec = AeicEntropyCodec( + geometry: geometry, + network: network, + coders: coders, + ); + final Float32List got = await codec.decodeToLatent( + r.u8('enc/bitstream'), + ); + expect(network.hyperCalls, 1); + expect(network.stageCalls, [0, 1, 2, 3]); + _expectSameFloats(got, r.f32('dec/y_hat'), '$name: y_hat'); + // The recording asserts the decoder's latent equals the encoder's; if + // that holds in Python it must hold here too. + expect(r.meta['decoded_y_hat_equals_encoder_y_hat'], isTrue); + _expectSameFloats(got, r.f32('enc/y_hat'), '$name: y_hat vs encoder'); + }); + + test('a single flipped bitstream byte does not still pass', () async { + // Byte 3 is the first payload byte of sub-stream 0 (1-byte flag + + // 2-byte size header), which the decoder loads straight into the rANS + // state, so flipping it must change the output. The *last* byte is a + // poor choice: renormalisation does not always consume the tail, and + // on two of these five recordings flipping it is genuinely a no-op. + final Uint8List mutated = Uint8List.fromList(r.u8('enc/bitstream')); + expect(mutated.length, greaterThan(4)); + mutated[3] ^= 0x01; + final AeicEntropyCodec codec = AeicEntropyCodec( + geometry: geometry, + network: _networkForMutation(r), + coders: coders, + ); + Float32List? got; + try { + got = await codec.decodeToLatent(mutated); + } catch (_) { + // Desync raising is an acceptable outcome; silently matching is not. + return; + } + expect( + _sameFloats(got, r.f32('dec/y_hat')), + isFalse, + reason: '$name: corrupting the stream changed nothing — the ' + 'comparison is vacuous', + ); + }); + }); + } +} + +/// A replay network for the mutated-stream test: it must NOT assert on its +/// inputs, because a desynchronised decode legitimately feeds it a different +/// `base`. It returns the recorded outputs regardless. +AeicEntropyNetwork _networkForMutation(_Recording r) => + _ReplayNetwork(r, strict: false); + +/// [AeicEntropyNetwork] that replays one `.aeicrec` recording. +/// +/// Returns the ORT tensors Python captured, and — when [strict] — asserts that +/// the tensors the Dart loop hands it are bit-for-bit the ones Python's own +/// decode loop handed the real graph. +class _ReplayNetwork implements AeicEntropyNetwork { + _ReplayNetwork(this.r, {this.strict = true}); + + final _Recording r; + final bool strict; + + int encodeCalls = 0; + int hyperCalls = 0; + final List stageCalls = []; + + @override + bool get supportsDecodeSide => true; + + @override + Future runEncodeSide(Float32List imageChw) async { + encodeCalls++; + return AeicEncodeSideTensors( + zQ: r.f32('enc/z_q'), + yQ: [for (var i = 0; i < 4; i++) r.f32('enc/yq$i')], + scales: [for (var i = 0; i < 4; i++) r.f32('enc/sc$i')], + ); + } + + @override + Future runHyperSynthesis(Float32List zQ) async { + hyperCalls++; + final Map call = r.calls[0]; + if (strict) { + _expectSameFloats( + zQ, + r.f32((call['inputs'] as Map)['z_q'] as String), + 'hyper_synthesis input z_q', + ); + } + return r.f32((call['outputs'] as Map)['base0'] as String); + } + + @override + Future runStage(int stage, Float32List base) async { + stageCalls.add(stage); + final Map call = r.calls[stage + 1]; + expect(call['stage'], stage, reason: 'call table is positional'); + if (strict) { + _expectSameFloats( + base, + r.f32((call['inputs'] as Map)['base'] as String), + 'stage $stage input base', + ); + } + final Map outputs = call['outputs'] as Map; + return AeicStageParams( + meansSupp: r.f32(outputs['means'] as String), + scalesSupp: r.f32(outputs['scales'] as String), + ); + } +} + +/// Reader for the `.aeicrec` container (magic "AEICREC1", little-endian): +/// 32-byte header, an 8-byte-aligned tensor blob, then a UTF-8 JSON index. +class _Recording { + _Recording(this.index, this.bytes) + : _entries = >{ + for (final Map e + in (index['entries'] as List) + .cast>()) + e['name'] as String: e, + }; + + final Map index; + final Uint8List bytes; + final Map> _entries; + + static _Recording load(String path) { + final Uint8List bytes = File(path).readAsBytesSync(); + final ByteData bd = ByteData.sublistView(bytes); + final String magic = ascii.decode(bytes.sublist(0, 8)); + if (magic != 'AEICREC1') { + throw FormatException('bad .aeicrec magic "$magic" in $path'); + } + final int version = bd.getUint32(8, Endian.little); + if (version != 1) { + throw FormatException('.aeicrec version $version in $path'); + } + final int indexOffset = bd.getUint64(16, Endian.little); + final int indexLength = bd.getUint32(24, Endian.little); + final Map index = + jsonDecode( + utf8.decode(bytes.sublist(indexOffset, indexOffset + indexLength)), + ) + as Map; + return _Recording(index, bytes); + } + + Map get meta => index['meta'] as Map; + + List> get calls => + (index['calls'] as List).cast>(); + + Map _entry(String name, String dtype) { + final Map? e = _entries[name]; + if (e == null) { + throw StateError('no entry "$name" in recording'); + } + if (e['dtype'] != dtype) { + throw StateError('entry "$name" is ${e['dtype']}, wanted $dtype'); + } + return e; + } + + Float32List f32(String name) { + final Map e = _entry(name, 'f32'); + return Float32List.sublistView( + bytes, + e['offset'] as int, + (e['offset'] as int) + (e['length'] as int), + ); + } + + Int16List i16(String name) { + final Map e = _entry(name, 'i16'); + return Int16List.sublistView( + bytes, + e['offset'] as int, + (e['offset'] as int) + (e['length'] as int), + ); + } + + Uint8List u8(String name) { + final Map e = _entry(name, 'u8'); + return Uint8List.sublistView( + bytes, + e['offset'] as int, + (e['offset'] as int) + (e['length'] as int), + ); + } +} + +void _expectSameBytes(Uint8List got, Uint8List want, String label) { + final int n = got.length < want.length ? got.length : want.length; + for (var i = 0; i < n; i++) { + if (got[i] != want[i]) { + fail( + '$label: first byte divergence at offset $i of ${want.length} ' + '(got 0x${got[i].toRadixString(16)}, ' + 'want 0x${want[i].toRadixString(16)})', + ); + } + } + expect( + got.length, + want.length, + reason: '$label: length differs (prefix matched)', + ); +} + +void _expectSameInts(List got, List want, String label) { + expect(got.length, want.length, reason: '$label: length'); + for (var i = 0; i < got.length; i++) { + if (got[i] != want[i]) { + fail( + '$label: first divergence at index $i of ${want.length} ' + '(got ${got[i]}, want ${want[i]})', + ); + } + } +} + +/// Exact equality, element for element — no tolerance. These are integers +/// carried in float32 (symbols + means), so "close" is not the bar. +void _expectSameFloats(Float32List got, Float32List want, String label) { + expect(got.length, want.length, reason: '$label: length'); + for (var i = 0; i < got.length; i++) { + if (got[i] != want[i]) { + fail( + '$label: first divergence at index $i of ${want.length} ' + '(got ${got[i]}, want ${want[i]}, ' + 'diff ${(got[i] - want[i]).abs()})', + ); + } + } +} + +bool _sameFloats(Float32List a, Float32List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +Directory _resolveGoldenDir() { + for (final String candidate in [ + 'test/services/golden', + '../test/services/golden', + 'golden', + ]) { + final Directory d = Directory(candidate); + if (d.existsSync()) return d; + } + return Directory('test/services/golden'); +} diff --git a/test/services/image_codec_entropy_test.dart b/test/services/image_codec_entropy_test.dart new file mode 100644 index 00000000..254c6c0d --- /dev/null +++ b/test/services/image_codec_entropy_test.dart @@ -0,0 +1,754 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/entropy_tables.dart'; +import 'package:meshcore_open/services/image_codec_backend.dart' + show AeicRansCoders; +import 'package:meshcore_open/services/image_codec_entropy.dart'; + +/// Golden vectors for the entropy layer — the arithmetic between the ONNX +/// tensors and the rANS coder. +/// +/// GENERATED BY: `aic/exp/export_entropy_layer_golden.py`, which runs the real +/// `torch` ops from `aic/aeic/src/codec/codec_practical.py` +/// (`get_mask_four_parts`, `sequeeze`, `torch.round`, `my_build_indexes`) on +/// inputs built from a closed-form integer recipe. Every constant in that +/// recipe is a power-of-two fraction or a plain float64 division, so Dart and +/// numpy reproduce the float32 inputs bit-for-bit and the comparison is real +/// rather than a re-implementation checking itself. +/// +/// Regenerate with: +/// cd /Users/Zach/Documents/mycode/aic +/// AEIC_DEVICE=cpu .venv/bin/python exp/export_entropy_layer_golden.py +/// +/// WHY THIS MATTERS: none of these failures are loud. A wrong mask permutation, +/// a `sequeeze` that folds the wrong channels, or a rounding tie resolved away +/// from zero instead of to even does not throw — it desynchronises rANS and +/// produces a sharp, plausible, wrong image. +class _Golden { + // --- small case: C = 8, H = 4, W = 4, squeezed length 32 --- + + /// `mask_i` flattened over `[1, 8, 4, 4]`, '1' where live. Straight from + /// `get_mask_four_parts(1, 8, 4, 4)`. + static const List masks = [ + '10100000101000001010000010100000010100000101000001010000010100000000101000001010000010100000101000000101000001010000010100000101', + '00000101000001010000010100000101000010100000101000001010000010100101000001010000010100000101000010100000101000001010000010100000', + '00001010000010100000101000001010000001010000010100000101000001011010000010100000101000001010000001010000010100000101000001010000', + '01010000010100000101000001010000101000001010000010100000101000000000010100000101000001010000010100001010000010100000101000001010', + ]; + + static const List> symbols = >[ + [-1, -1, -2, 4, 0, 0, 6, -3, 2, 1, 1, -1, -5, 1, 0, 0, -1, -1, -2, 4, + 0, 0, 5, -3, 2, 1, 1, -1, -5, 1, 0, 0], + [-1, -1, 4, 4, 0, 0, 0, 5, 2, 1, -1, -1, 3, -5, 1, 0, -1, -2, 4, 4, + 0, 0, 0, 5, 1, 1, -1, -1, 3, -5, 1, 0], + [-1, -2, -2, 4, 1, 0, 0, 5, 2, 0, -1, -1, 3, -5, 1, 0, -1, -2, -2, 4, + 0, 0, 0, 5, 2, -1, -1, -1, 3, -5, 1, 0], + [-1, -1, -2, -2, 0, 0, 5, -3, 2, 1, -1, -1, -5, -5, 0, 0, -1, -1, -2, + -2, 0, 0, 5, -3, 2, 1, -1, -1, -5, 1, 0, 0], + ]; + + static const List> indexes = >[ + [-1, 6, -1, 8, 12, 15, 13, 16, 0, 9, 3, 10, 13, 16, 14, 17, 0, 9, 3, + 10, 13, 16, 14, 17, 5, 11, 7, 12, 15, 17, 15, 18], + [14, 11, 15, 12, 7, 0, 8, 2, 15, 13, 16, 14, 9, 4, 11, 6, 15, 13, 16, + 14, 10, 4, 11, 6, 16, 14, 17, 15, 11, 7, 12, 9], + [11, 14, 12, 15, -1, 8, 0, 9, 12, 16, 13, 16, 2, 10, 5, 11, 13, 16, + 13, 16, 3, 10, 5, 11, 14, 17, 15, 17, 7, 12, 8, 13], + [5, -1, 7, 0, 15, 12, 15, 13, 8, 1, 10, 4, 16, 14, 16, 14, 8, 2, 10, + 4, 16, 14, 16, 14, 11, 6, 12, 8, 17, 15, 17, 16], + ]; + + // --- full case: C = 256, H = W = 16 (the shipping geometry) --- + // + // 16,384 symbols per stage is too much to embed, so the golden is a + // fingerprint: total, extremes, and the first and last twelve values. Any + // permutation error moves the head or the tail; any arithmetic error moves + // the sum. + static const List fullSymbolSum = [-17, 40, -10, 29]; + static const List fullIndexSum = [412240, 412285, 412231, 412219]; + static const List> fullSymbolHead = >[ + [-1, -4, -2, 1, 4, -1, -5, -2, 0, 3, -1, -5], + [-3, 0, 3, 5, 0, -4, -1, 2, 4, 1, -4, -2], + [0, 3, -1, 2, -3, 0, 2, -1, 1, -4, -1, 2], + [-3, -1, 2, -2, 1, 3, -2, 1, 4, 0, 3, 5], + ]; + static const List> fullSymbolTail = >[ + [-4, -2, 1, 4, -2, 1, -3, 0, 3, -2, -6, -3], + [-2, 0, -3, -1, 2, 5, 0, -4, -1, 1, 4, 6], + [-5, -2, 0, 3, -1, -6, -3, 0, 2, 5, 1, -4], + [-1, 1, 4, 0, -4, -2, 1, 3, 6, 2, -2, 0], + ]; + static const List> fullIndexHead = >[ + [-1, -1, -1, 1, 0, 4, 4, 6, 6, 8, 8, 9], + [0, 0, 4, 3, 6, 6, 8, 7, 9, 9, 10, 10], + [0, 2, 2, 5, 4, 7, 7, 8, 8, 10, 10, 11], + [-1, -1, 0, 0, 3, 2, 5, 5, 7, 7, 9, 8], + ]; + static const List> fullIndexTail = >[ + [16, 17, 17, 18, 17, 18, 18, 18, 18, 19, 19, 19], + [16, 16, 17, 16, 17, 17, 18, 18, 18, 18, 19, 18], + [16, 16, 16, 17, 17, 17, 17, 18, 18, 18, 18, 19], + [17, 17, 17, 17, 18, 18, 18, 18, 19, 19, 19, 19], + ]; + + /// `my_build_indexes` on a spread of scales, isolated from everything else. + static const List probeScales = [ + 0.0, + 9.999999974752427e-07, + 0.05000000074505806, + 0.07989999651908875, + 0.07999999821186066, + 0.10999999940395355, + 0.11000010371208191, + 0.5, + 1.0, + 2.0, + 7.5, + 63.900001525878906, + 255.89999389648438, + 256.0, + 10000.0, + ]; + static const List probeIndexes = [ + -1, -1, -1, -1, 0, 0, 0, 12, 17, 23, 34, 51, 62, 63, 63, + ]; +} + +/// The same closed-form recipe the generator uses, in Dart. +/// +/// `y`, `means_supp` and `scales_supp` for a `[1, C, H, W]` tensor. Kept +/// byte-identical to `recipe()` in `export_entropy_layer_golden.py`. +({Float32List y, Float32List means, Float32List scales}) _recipe( + int channels, + int height, + int width, +) { + final n = channels * height * width; + final y = Float32List(n); + final means = Float32List(n); + final scales = Float32List(n); + var i = 0; + for (var c = 0; c < channels; c++) { + for (var h = 0; h < height; h++) { + for (var w = 0; w < width; w++, i++) { + y[i] = ((c * 3 + h * 17 + w * 11) % 61 - 30) * 0.125; + means[i] = ((c * 7 + h * 13 + w * 29) % 97 - 48) * 0.0625; + scales[i] = ((c * 11 + h * 5 + w * 3) % 700) * 0.01 + 0.001; + } + } + } + return (y: y, means: means, scales: scales); +} + +/// One stage of `compress()`: mask, quantize, fold, index. +({Int16List symbols, Int16List indexes}) _runStage( + AeicMaskSet masks, + Float32List y, + Float32List meansSupp, + Float32List scalesSupp, + int stage, +) { + final means = masks.applyMask(meansSupp, stage); + final scales = masks.applyMask(scalesSupp, stage); + final maskedY = masks.applyMask(y, stage); + final yq = Float32List(y.length); + for (var i = 0; i < y.length; i++) { + yq[i] = roundHalfToEven(f32(maskedY[i] - means[i])); + } + return ( + symbols: aeicToSymbols(masks.squeeze(yq)), + indexes: aeicBuildIndexes(masks.squeeze(scales)), + ); +} + +void main() { + group('AeicEntropyGeometry', () { + test('512x512 ft32 matches the shapes the bitstream format assumes', () { + final g = AeicEntropyGeometry.forResolution(512); + expect(g.yShape, [1, 256, 16, 16]); + expect(g.zShape, [1, 128, 4, 4]); + expect(g.squeezedChannels, 64); + // From aic/results/rans_port_spec.md §1. + expect(g.zElements, 2048); + expect(g.symbolsPerStage, 16384); + expect(g.totalEntries, 67584); + }); + + test('z is ceil(y/4), not floor', () { + // 320/32 = 10 -> z must be 3, matching compress()'s reflect padding. + final g = AeicEntropyGeometry.forResolution(320); + expect(g.yHeight, 10); + expect(g.zHeight, 3); + }); + + test('rejects a resolution g_a cannot downsample by 32', () { + expect(() => AeicEntropyGeometry.forResolution(500), throwsArgumentError); + expect(() => AeicEntropyGeometry.forResolution(0), throwsArgumentError); + }); + }); + + group('AeicMaskSet', () { + test('the four masks are get_mask_four_parts, element for element', () { + final masks = AeicMaskSet( + AeicEntropyGeometry.forResolution(128, yChannels: 8), + ); + // 128/32 = 4, so this is exactly the C=8 H=W=4 case in the golden. + for (var stage = 0; stage < 4; stage++) { + final tensor = masks.maskTensor(stage); + final actual = tensor.map((v) => v == 1.0 ? '1' : '0').join(); + expect(actual, _Golden.masks[stage], reason: 'mask_$stage'); + } + }); + + test('every position is claimed by exactly one stage, per channel', () { + final geometry = AeicEntropyGeometry.forResolution(512); + final masks = AeicMaskSet(geometry); + final counts = Uint8List(geometry.yElements); + for (var stage = 0; stage < 4; stage++) { + final tensor = masks.maskTensor(stage); + for (var i = 0; i < tensor.length; i++) { + counts[i] += tensor[i].toInt(); + } + } + expect(counts.every((c) => c == 1), isTrue); + }); + + test('each mask carries exactly a quarter of the tensor', () { + final geometry = AeicEntropyGeometry.forResolution(512); + final masks = AeicMaskSet(geometry); + for (var stage = 0; stage < 4; stage++) { + final live = masks + .maskTensor(stage) + .where((v) => v == 1.0) + .length; + expect(live, geometry.symbolsPerStage); + } + }); + + test('squeeze then unsqueeze is the identity on a masked tensor', () { + final geometry = AeicEntropyGeometry.forResolution(512); + final masks = AeicMaskSet(geometry); + final recipe = _recipe(256, 16, 16); + for (var stage = 0; stage < 4; stage++) { + final masked = masks.applyMask(recipe.y, stage); + final restored = masks.unsqueeze(masks.squeeze(masked), stage); + expect(restored, masked, reason: 'stage $stage'); + } + }); + + test('mergeContext replaces exactly the stage mask', () { + final geometry = AeicEntropyGeometry.forResolution(512); + final masks = AeicMaskSet(geometry); + final base = _recipe(256, 16, 16).y; + final stageLatent = masks.applyMask(_recipe(256, 16, 16).means, 2); + final merged = masks.mergeContext(base, stageLatent, 2); + final mask = masks.maskTensor(2); + for (var i = 0; i < merged.length; i++) { + expect(merged[i], mask[i] == 1.0 ? stageLatent[i] : base[i]); + } + }); + }); + + group('roundHalfToEven', () { + test('ties go to even, unlike Dart round()', () { + expect(roundHalfToEven(0.5), 0.0); + expect(roundHalfToEven(1.5), 2.0); + expect(roundHalfToEven(2.5), 2.0); + expect(roundHalfToEven(-0.5), 0.0); + expect(roundHalfToEven(-1.5), -2.0); + expect(roundHalfToEven(-2.5), -2.0); + // Dart disagrees on every one of those ties. + expect((-0.5).roundToDouble(), -1.0); + }); + + test('non-ties are ordinary rounding', () { + expect(roundHalfToEven(0.49), 0.0); + expect(roundHalfToEven(0.51), 1.0); + expect(roundHalfToEven(-1.51), -2.0); + expect(roundHalfToEven(-1.49), -1.0); + expect(roundHalfToEven(7.0), 7.0); + }); + }); + + group('aeicBuildIndexes', () { + test('matches my_build_indexes on the probe scales', () { + final scales = Float32List.fromList(_Golden.probeScales); + expect(aeicBuildIndexes(scales), _Golden.probeIndexes); + }); + + test('the 0.08 threshold is strict, and clamps do not leak', () { + // 0.08 itself is NOT skipped; it clamps to row 0 because ln(0.08) is + // below ln(0.11). + expect(aeicBuildIndexes(Float32List.fromList([0.08])).first, 0); + expect( + aeicBuildIndexes(Float32List.fromList([0.0799])).first, + -1, + ); + expect( + aeicBuildIndexes(Float32List.fromList([1e9])).first, + kAeicScalesLevels - 1, + ); + }); + }); + + group('aeicZIndexes', () { + test('is the channel arange broadcast over H*W', () { + final geometry = AeicEntropyGeometry.forResolution(512); + final indexes = aeicZIndexes(geometry); + expect(indexes.length, 2048); + expect(indexes.first, 0); + expect(indexes[15], 0); + expect(indexes[16], 1); + expect(indexes.last, 127); + // Verified against results/golden/vectors/kodim01.gv, which stores the + // exact int16 array the C++ coder was given. + for (var i = 0; i < indexes.length; i++) { + expect(indexes[i], i ~/ 16); + } + }); + }); + + group('aeicToSymbols', () { + test('rejects a value the int16 wire format cannot carry', () { + expect( + () => aeicToSymbols(Float32List.fromList([40000.0])), + throwsStateError, + ); + expect( + () => aeicToSymbols(Float32List.fromList([1.5])), + throwsStateError, + ); + expect(aeicToSymbols(Float32List.fromList([-3.0])), [-3]); + }); + }); + + group('aeicRgbToChw', () { + test('reproduces ToTensor + Normalize([0.5], [0.5])', () { + final chw = aeicRgbToChw(Uint8List.fromList([0, 128, 255]), 1); + expect(chw.length, 3); + expect(chw[0], -1.0); + expect(chw[1], closeTo(0.00392, 1e-4)); + expect(chw[2], 1.0); + }); + + test('is channel-planar, not interleaved', () { + final rgb = Uint8List(4 * 3); + for (var i = 0; i < 4; i++) { + rgb[i * 3] = 255; // R + rgb[i * 3 + 1] = 0; // G + rgb[i * 3 + 2] = 0; // B + } + final chw = aeicRgbToChw(rgb, 2); + expect(chw.sublist(0, 4), [1.0, 1.0, 1.0, 1.0]); + expect(chw.sublist(4, 8), [-1.0, -1.0, -1.0, -1.0]); + }); + + test('rejects a byte count that is not the stated square', () { + expect(() => aeicRgbToChw(Uint8List(11), 2), throwsArgumentError); + }); + }); + + group('four-stage symbol packing (golden)', () { + test('C=8 H=4 W=4: symbols and indexes match torch exactly', () { + final geometry = AeicEntropyGeometry.forResolution(128, yChannels: 8); + final masks = AeicMaskSet(geometry); + final recipe = _recipe(8, 4, 4); + for (var stage = 0; stage < 4; stage++) { + final out = _runStage( + masks, + recipe.y, + recipe.means, + recipe.scales, + stage, + ); + expect(out.symbols, _Golden.symbols[stage], reason: 'symbols $stage'); + expect(out.indexes, _Golden.indexes[stage], reason: 'indexes $stage'); + } + }); + + test('C=256 H=W=16 (shipping geometry): fingerprint matches torch', () { + final geometry = AeicEntropyGeometry.forResolution(512); + final masks = AeicMaskSet(geometry); + final recipe = _recipe(256, 16, 16); + for (var stage = 0; stage < 4; stage++) { + final out = _runStage( + masks, + recipe.y, + recipe.means, + recipe.scales, + stage, + ); + expect(out.symbols.length, 16384); + expect( + out.symbols.fold(0, (a, b) => a + b), + _Golden.fullSymbolSum[stage], + reason: 'symbol sum $stage', + ); + expect(out.symbols.sublist(0, 12), _Golden.fullSymbolHead[stage]); + expect(out.symbols.sublist(16372), _Golden.fullSymbolTail[stage]); + expect( + out.indexes.fold(0, (a, b) => a + b), + _Golden.fullIndexSum[stage], + reason: 'index sum $stage', + ); + expect(out.indexes.sublist(0, 12), _Golden.fullIndexHead[stage]); + expect(out.indexes.sublist(16372), _Golden.fullIndexTail[stage]); + } + }); + }); + + group('AeicEntropyCodec', () { + test('encode pushes z, y0, y1, y2, y3 in that order and no other', () async { + final geometry = AeicEntropyGeometry.forResolution(512); + final network = _FakeNetwork(geometry); + final coders = _RecordingCoders(); + final codec = AeicEntropyCodec( + geometry: geometry, + network: network, + coders: coders, + ); + final progress = []; + final stream = await codec.encode( + Uint8List(512 * 512 * 3), + onProgress: progress.add, + ); + + expect(coders.encoder.groups, [ + kAeicZCdfGroup, + kAeicYCdfGroup, + kAeicYCdfGroup, + kAeicYCdfGroup, + kAeicYCdfGroup, + ]); + expect(coders.encoder.lengths, [2048, 16384, 16384, 16384, 16384]); + expect(stream, isNotEmpty); + expect(progress.last, 1.0); + // The image really was normalized before the graph saw it: 0 -> -1. + expect(network.lastInput!.first, -1.0); + }); + + test('encode refuses a graph that returns the wrong z size', () async { + final geometry = AeicEntropyGeometry.forResolution(512); + final codec = AeicEntropyCodec( + geometry: geometry, + network: _FakeNetwork(geometry, zElements: 7), + coders: _RecordingCoders(), + ); + await expectLater( + codec.encode(Uint8List(512 * 512 * 3)), + throwsStateError, + ); + }); + + test('encode honours shouldCancel between stages', () async { + final geometry = AeicEntropyGeometry.forResolution(512); + final codec = AeicEntropyCodec( + geometry: geometry, + network: _FakeNetwork(geometry), + coders: _RecordingCoders(), + ); + await expectLater( + codec.encode(Uint8List(512 * 512 * 3), shouldCancel: () => true), + throwsA(isA()), + ); + }); + + test('decode reports a send-side-only graph instead of guessing', () async { + final geometry = AeicEntropyGeometry.forResolution(512); + final codec = AeicEntropyCodec( + geometry: geometry, + network: _FakeNetwork(geometry, decodeSide: false), + coders: _RecordingCoders(), + ); + await expectLater( + codec.decodeToLatent(Uint8List(16)), + throwsA(isA()), + ); + }); + + test('decode walks the stages in order and rebuilds y_hat', () async { + final geometry = AeicEntropyGeometry.forResolution(512); + final network = _FakeNetwork(geometry); + final coders = _RecordingCoders(); + final codec = AeicEntropyCodec( + geometry: geometry, + network: network, + coders: coders, + ); + final yHat = await codec.decodeToLatent(Uint8List(16)); + + expect(network.stageCalls, [0, 1, 2, 3]); + expect(coders.decoder.groups, [ + kAeicZCdfGroup, + kAeicYCdfGroup, + kAeicYCdfGroup, + kAeicYCdfGroup, + kAeicYCdfGroup, + ]); + expect(yHat.length, geometry.yElements); + // The fake decoder returns symbol 1 everywhere and the fake network + // returns means 0, so every position of y_hat must be exactly 1 — which + // only holds if the four masks tile the tensor and each stage's squeezed + // symbols were unsqueezed back into the right channel group. + expect(yHat.every((v) => v == 1.0), isTrue); + }); + }); + // The end-to-end proof: the entropy layer plus the pure-Dart range coder + // reproduce, byte for byte, the bitstreams the C++ coder produced for real + // images — and decode them back to the same symbols. + group('bitstream round trip against the C++ golden vectors', () { + final Directory goldenDir = _resolveGoldenDir(); + final EntropyTables tables = EntropyTables.parse( + File('${goldenDir.path}/aeic_cdf_ft32.bin').readAsBytesSync(), + ); + final geometry = AeicEntropyGeometry.forResolution(512); + final masks = AeicMaskSet(geometry); + + // kodim01 is the plain case; kodim23 and image2 both carry index -1 + // (scales below 0.08), which the coder must skip on encode and read back as + // a literal 0. + for (final name in ['kodim01', 'kodim23', 'image2']) { + test('$name: symbols -> bitstream -> symbols', () async { + final vector = _readGoldenVector( + File('${goldenDir.path}/vectors/$name.gv').readAsBytesSync(), + ); + final expectedStream = File( + '${goldenDir.path}/vectors/$name.bin', + ).readAsBytesSync(); + + // Rebuild the pre-fold tensors the graph would have produced. `squeeze` + // is a bijection on masked tensors, so unsqueezing the golden arrays + // recovers a legitimate input and the loop's own fold has to invert it. + final yQ = []; + final scales = []; + for (var stage = 0; stage < 4; stage++) { + final symbols = vector['y_q$stage']!; + final indexes = vector['y_indexes$stage']!; + final squeezedY = Float32List(symbols.length); + final squeezedS = Float32List(indexes.length); + for (var i = 0; i < symbols.length; i++) { + squeezedY[i] = symbols[i].toDouble(); + squeezedS[i] = _scaleForIndex(indexes[i]); + } + // Self-check: the synthesized scales must land back on the exact + // golden indexes, or this test is measuring the wrong thing. + expect(aeicBuildIndexes(squeezedS), indexes, reason: 'stage $stage'); + yQ.add(masks.unsqueeze(squeezedY, stage)); + scales.add(masks.unsqueeze(squeezedS, stage)); + } + final zQ = Float32List(vector['z_q']!.length); + for (var i = 0; i < zQ.length; i++) { + zQ[i] = vector['z_q']![i].toDouble(); + } + + final network = _ReplayNetwork( + geometry: geometry, + tensors: AeicEncodeSideTensors(zQ: zQ, yQ: yQ, scales: scales), + ); + final codec = AeicEntropyCodec( + geometry: geometry, + network: network, + coders: AeicRansCoders(tables), + ); + + // ENCODE: byte-for-byte against the C++ coder's output. + final stream = await codec.encode(Uint8List(512 * 512 * 3)); + expect(stream, expectedStream, reason: 'bitstream for $name'); + + // DECODE: the same bytes back to the same symbols. means are zero, so + // y_hat is exactly the four stages' symbols tiled back into place — + // which only holds if every mask, fold and unfold agrees with encode. + final yHat = await codec.decodeToLatent(stream); + var expected = Float32List(geometry.yElements); + for (var stage = 0; stage < 4; stage++) { + expected = masks.mergeContext(expected, yQ[stage], stage); + } + expect(yHat, expected, reason: 'y_hat for $name'); + expect(network.stageCalls, [0, 1, 2, 3]); + }); + } + }); +} + +Directory _resolveGoldenDir() { + for (final candidate in [ + 'test/services/golden', + '../test/services/golden', + 'golden', + ]) { + final dir = Directory(candidate); + if (dir.existsSync()) { + return dir; + } + } + throw StateError( + 'golden vectors not found; expected test/services/golden relative to the ' + 'package root', + ); +} + +/// Parses the `.gv` container documented in `aic/results/rans_port_spec.md` §10. +Map _readGoldenVector(Uint8List bytes) { + final data = ByteData.sublistView(bytes); + const magic = [0x41, 0x45, 0x49, 0x43, 0x47, 0x56, 0x00, 0x01]; + for (var i = 0; i < magic.length; i++) { + if (bytes[i] != magic[i]) { + throw StateError('not a .gv container'); + } + } + final count = data.getUint32(12, Endian.little); + final names = []; + final counts = []; + final dtypes = []; + var off = 16; + for (var i = 0; i < count; i++) { + final raw = bytes.sublist(off, off + 16); + final end = raw.indexOf(0); + names.add(String.fromCharCodes(raw.sublist(0, end < 0 ? 16 : end))); + dtypes.add(data.getUint32(off + 16, Endian.little)); + counts.add(data.getUint32(off + 20, Endian.little)); + off += 24; + } + final out = {}; + for (var i = 0; i < count; i++) { + if (dtypes[i] != 0) { + throw StateError('${names[i]} is not int16'); + } + final values = Int16List(counts[i]); + for (var j = 0; j < counts[i]; j++) { + values[j] = data.getInt16(off + j * 2, Endian.little); + } + off += counts[i] * 2; + out[names[i]] = values; + } + return out; +} + +/// A scale that `my_build_indexes` maps back to exactly [index]. +/// +/// Row centres, so float32 rounding cannot push one over a boundary; index -1 +/// means "skipped", which any scale below 0.08 produces. +double _scaleForIndex(int index) { + if (index < 0) { + return 0.0; + } + return f32(math.exp(kAeicLogScaleMin + (index + 0.5) * kAeicLogScaleStep)); +} + +/// Replays fixed tensors as if they came from the graph, for both directions. +class _ReplayNetwork implements AeicEntropyNetwork { + final AeicEntropyGeometry geometry; + final AeicEncodeSideTensors tensors; + final List stageCalls = []; + + _ReplayNetwork({required this.geometry, required this.tensors}); + + @override + bool get supportsDecodeSide => true; + + @override + Future runEncodeSide(Float32List imageChw) async => + tensors; + + @override + Future runHyperSynthesis(Float32List zQ) async { + // The real h_s consumes z_hat; here the only thing under test is that the + // decoded z symbols reach it. Assert that and hand back a zero context. + expect(zQ.length, geometry.zElements); + for (var i = 0; i < zQ.length; i++) { + expect(zQ[i], tensors.zQ[i], reason: 'z symbol $i'); + } + return Float32List(geometry.yElements); + } + + @override + Future runStage(int stage, Float32List base) async { + stageCalls.add(stage); + return AeicStageParams( + meansSupp: Float32List(geometry.yElements), + scalesSupp: tensors.scales[stage], + ); + } +} + +/// A stand-in for the ONNX graph: shapes and call order, no arithmetic. +class _FakeNetwork implements AeicEntropyNetwork { + final AeicEntropyGeometry geometry; + final int? zElements; + final bool decodeSide; + final List stageCalls = []; + Float32List? lastInput; + + _FakeNetwork(this.geometry, {this.zElements, this.decodeSide = true}); + + @override + bool get supportsDecodeSide => decodeSide; + + @override + Future runEncodeSide(Float32List imageChw) async { + lastInput = imageChw; + return AeicEncodeSideTensors( + zQ: Float32List(zElements ?? geometry.zElements), + yQ: [ + for (var i = 0; i < 4; i++) Float32List(geometry.yElements), + ], + scales: [ + for (var i = 0; i < 4; i++) + Float32List(geometry.yElements)..fillRange(0, geometry.yElements, 1.0), + ], + ); + } + + @override + Future runHyperSynthesis(Float32List zQ) async => + Float32List(geometry.yElements); + + @override + Future runStage(int stage, Float32List base) async { + stageCalls.add(stage); + return AeicStageParams( + meansSupp: Float32List(geometry.yElements), + scalesSupp: Float32List(geometry.yElements) + ..fillRange(0, geometry.yElements, 1.0), + ); + } +} + +class _RecordingEncoder implements AeicRansEncoder { + final List groups = []; + final List lengths = []; + + @override + void pushSymbols(Int16List symbols, Int16List indexes, int cdfGroup) { + expect(symbols.length, indexes.length); + groups.add(cdfGroup); + lengths.add(symbols.length); + } + + @override + Uint8List finish() => Uint8List.fromList([0x11, 4, 0]); +} + +class _RecordingDecoder implements AeicRansDecoder { + final List groups = []; + + @override + Int16List decodeStream(Int16List indexes, int cdfGroup) { + groups.add(cdfGroup); + return Int16List(indexes.length)..fillRange(0, indexes.length, 1); + } +} + +class _RecordingCoders implements AeicRansCoderFactory { + final _RecordingEncoder encoder = _RecordingEncoder(); + final _RecordingDecoder decoder = _RecordingDecoder(); + + @override + AeicRansEncoder createEncoder() => encoder; + + @override + AeicRansDecoder createDecoder(Uint8List bitstream) => decoder; +} diff --git a/test/services/image_codec_png_test.dart b/test/services/image_codec_png_test.dart new file mode 100644 index 00000000..f95c7bbf --- /dev/null +++ b/test/services/image_codec_png_test.dart @@ -0,0 +1,47 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/image_codec_service.dart'; + +/// Covers the one piece of the decode path that can be executed without a model +/// file, a device or ONNX Runtime: turning the backend's packed RGB output into +/// PNG bytes a widget can render. +/// +/// It needs the engine's image codecs, hence the binding. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ImageCodecService.rgbToPng', () { + test('encodes a PNG with the right magic bytes and dimensions', () async { + const side = 8; + final rgb = Uint8List(side * side * 3); + for (var i = 0; i < rgb.length; i++) { + rgb[i] = i & 0xFF; + } + + final png = await ImageCodecService.rgbToPng(rgb, side); + + expect(png.sublist(0, 8), [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ]); + // IHDR width/height, big-endian at offsets 16 and 20. + final header = ByteData.sublistView(png); + expect(header.getUint32(16), side); + expect(header.getUint32(20), side); + }); + + test('rejects a buffer that is not RGB at the stated size', () async { + await expectLater( + ImageCodecService.rgbToPng(Uint8List(10), 8), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/services/rans_coder_test.dart b/test/services/rans_coder_test.dart new file mode 100644 index 00000000..dff1c68b --- /dev/null +++ b/test/services/rans_coder_test.dart @@ -0,0 +1,263 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/entropy_tables.dart'; +import 'package:meshcore_open/services/rans_coder.dart'; + +/// Golden-vector conformance for the pure-Dart rANS port. +/// +/// The bar is byte-identical: encoding the golden symbol/index arrays must +/// reproduce the exact bitstream the C++ coder produced, and decoding that +/// bitstream must reproduce the exact symbols. A single differing byte +/// desynchronises rANS and silently corrupts most of an image. +void main() { + final Directory goldenDir = _resolveGoldenDir(); + final EntropyTables tables = EntropyTables.parse( + File('${goldenDir.path}/aeic_cdf_ft32.bin').readAsBytesSync(), + ); + final Map manifest = + jsonDecode(File('${goldenDir.path}/manifest.json').readAsStringSync()) + as Map; + final List> images = (manifest['images'] as List) + .cast>(); + final List> synthetic = + (manifest['synthetic'] as List).cast>(); + + test('golden corpus is complete', () { + expect(images.length, 10); + expect(synthetic.length, 7); + expect(manifest['stream_parts'], 2); + expect(manifest['reference_port_selfcheck'], { + 'ok': 17, + 'total': 17, + }); + }); + + group('image vectors', () { + for (final Map rec in images) { + final String stem = rec['stem'] as String; + test('$stem encodes and decodes byte-identically', () { + final Map> arrays = _readGoldenVector( + File( + '${goldenDir.path}/vectors/${rec['vector_file']}', + ).readAsBytesSync(), + ); + final Uint8List want = File( + '${goldenDir.path}/vectors/${rec['bitstream_file']}', + ).readAsBytesSync(); + + // Call order is part of the format: z, then y0..y3. + final List<_Call> calls = <_Call>[ + _Call(arrays['z_q']!, arrays['z_indexes']!, 0), + for (var i = 0; i < 4; i++) + _Call(arrays['y_q$i']!, arrays['y_indexes$i']!, 1), + ]; + + final RansEncoder encoder = RansEncoder(tables); + for (final _Call c in calls) { + encoder.encodeWithIndexes(c.symbols, c.indexes, c.group); + } + _expectSameBytes(encoder.finish(), want, stem); + + // One decoder, five incremental calls sharing the sub-stream states. + final RansDecoder decoder = RansDecoder(tables, want); + for (final _Call c in calls) { + _expectSameSymbols(decoder.decodeStream(c.indexes, c.group), c, stem); + } + + final List parts = parseRansContainer(want); + expect( + parts.map((Uint8List p) => p.length).toList(), + (rec['substream_sizes'] as List).cast(), + ); + expect(want[0], rec['container_flag']); + }); + } + }); + + group('synthetic vectors', () { + for (final Map rec in synthetic) { + final String name = rec['name'] as String; + test('$name encodes and decodes byte-identically', () { + final int group = rec['cdf_group'] as int; + final Map> arrays = _readGoldenVector( + File( + '${goldenDir.path}/vectors/${rec['vector_file']}', + ).readAsBytesSync(), + ); + final Uint8List want = File( + '${goldenDir.path}/vectors/${rec['bitstream_file']}', + ).readAsBytesSync(); + final _Call call = _Call(arrays['symbols']!, arrays['indexes']!, group); + + final RansEncoder encoder = RansEncoder(tables); + encoder.encodeWithIndexes(call.symbols, call.indexes, call.group); + _expectSameBytes(encoder.finish(), want, name); + + final RansDecoder decoder = RansDecoder(tables, want); + _expectSameSymbols( + decoder.decodeStream(call.indexes, call.group), + call, + name, + ); + + final List parts = parseRansContainer(want); + expect( + parts.map((Uint8List p) => p.length).toList(), + (rec['substream_sizes'] as List).cast(), + ); + expect(want[0], rec['container_flag']); + }); + } + }); + + test('container round-trips through build/parse', () { + final List parts = [ + Uint8List.fromList([1, 2, 3, 4, 5]), + Uint8List.fromList([9, 8, 7]), + ]; + final Uint8List packed = buildRansContainer(parts); + expect(packed[0], 0x11); + final List back = parseRansContainer(packed); + expect(back.length, 2); + expect(back[0], parts[0]); + expect(back[1], parts[1]); + }); + + test('a flipped bitstream byte would be caught', () { + // Guards the comparison itself against being vacuous. + final Map> arrays = _readGoldenVector( + File('${goldenDir.path}/vectors/synth_y_tiny.gv').readAsBytesSync(), + ); + final Uint8List want = File( + '${goldenDir.path}/vectors/synth_y_tiny.bin', + ).readAsBytesSync(); + final Uint8List mutated = Uint8List.fromList(want); + mutated[mutated.length - 1] ^= 0x01; + final RansEncoder encoder = RansEncoder(tables); + encoder.encodeWithIndexes(arrays['symbols']!, arrays['indexes']!, 1); + final Uint8List got = encoder.finish(); + expect(got, equals(want)); + expect(got, isNot(equals(mutated))); + }); + + test('encoder rejects a second finish()', () { + final RansEncoder encoder = RansEncoder(tables); + encoder.finish(); + expect(encoder.finish, throwsStateError); + }); +} + +class _Call { + _Call(this.symbols, this.indexes, this.group); + + final List symbols; + final List indexes; + final int group; +} + +void _expectSameBytes(Uint8List got, Uint8List want, String label) { + final int n = got.length < want.length ? got.length : want.length; + for (var i = 0; i < n; i++) { + if (got[i] != want[i]) { + fail( + '$label: first byte divergence at offset $i of ${want.length} ' + '(got 0x${got[i].toRadixString(16)}, ' + 'want 0x${want[i].toRadixString(16)})', + ); + } + } + expect( + got.length, + want.length, + reason: '$label: length differs (prefix matched)', + ); +} + +void _expectSameSymbols(Int16List got, _Call call, String label) { + expect(got.length, call.symbols.length, reason: '$label: length'); + for (var i = 0; i < got.length; i++) { + // idx < 0 is asymmetric: it emits nothing on encode, decodes as literal 0. + final int want = call.indexes[i] < 0 ? 0 : call.symbols[i]; + if (got[i] != want) { + fail( + '$label: first symbol divergence at $i ' + '(got ${got[i]}, want $want, index ${call.indexes[i]})', + ); + } + } +} + +Directory _resolveGoldenDir() { + for (final String candidate in [ + 'test/services/golden', + '../test/services/golden', + 'golden', + ]) { + final Directory d = Directory(candidate); + if (d.existsSync()) return d; + } + return Directory('test/services/golden'); +} + +/// Reads a `.gv` golden-vector container. +/// +/// char[8] magic "AEICGV\0\x01", u32 version, u32 nArrays, +/// nArrays x { char[16] name, u32 dtype (0=int16, 1=int32), u32 count }, +/// then the payloads back to back, little-endian. +Map> _readGoldenVector(Uint8List raw) { + const List magic = [0x41, 0x45, 0x49, 0x43, 0x47, 0x56, 0x00, 0x01]; + for (var i = 0; i < magic.length; i++) { + if (raw[i] != magic[i]) { + throw FormatException('bad .gv magic at byte $i'); + } + } + final ByteData bd = ByteData.view( + raw.buffer, + raw.offsetInBytes, + raw.lengthInBytes, + ); + final int version = bd.getUint32(8, Endian.little); + if (version != 1) { + throw FormatException('unsupported .gv version $version'); + } + final int n = bd.getUint32(12, Endian.little); + var off = 16; + final List names = []; + final List dtypes = []; + final List counts = []; + for (var i = 0; i < n; i++) { + final List nameBytes = raw.sublist(off, off + 16); + var end = nameBytes.indexOf(0); + if (end < 0) end = nameBytes.length; + names.add(ascii.decode(nameBytes.sublist(0, end))); + dtypes.add(bd.getUint32(off + 16, Endian.little)); + counts.add(bd.getUint32(off + 20, Endian.little)); + off += 24; + } + final Map> out = >{}; + for (var k = 0; k < n; k++) { + final int count = counts[k]; + if (dtypes[k] == 0) { + final Int16List a = Int16List(count); + for (var i = 0; i < count; i++) { + a[i] = bd.getInt16(off + i * 2, Endian.little); + } + off += count * 2; + out[names[k]] = a; + } else { + final Int32List a = Int32List(count); + for (var i = 0; i < count; i++) { + a[i] = bd.getInt32(off + i * 4, Endian.little); + } + off += count * 4; + out[names[k]] = a; + } + } + if (off != raw.length) { + throw FormatException('.gv trailing data: $off of ${raw.length}'); + } + return out; +} diff --git a/test/services/received_image_store_test.dart b/test/services/received_image_store_test.dart new file mode 100644 index 00000000..46c31943 --- /dev/null +++ b/test/services/received_image_store_test.dart @@ -0,0 +1,1149 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/services/image_chunk_transport.dart'; +import 'package:meshcore_open/services/received_image_blob_store_io.dart'; +import 'package:meshcore_open/services/received_image_store.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; + +/// Deterministic stand-in for `ImageCodecService`. +class _FakeDecoder implements ReceivedImageDecoder { + @override + ImageCodecAvailability availability; + + @override + bool isBusy; + + /// null -> `decodeBitstream` returns null (codec cannot decode at all) + /// 'fail'-> returns a result with status failed + /// 'throw'-> throws + /// 'ok' -> returns a PNG + String mode; + + Uint8List png; + int calls = 0; + int cancels = 0; + + /// Concurrency witness: a decode peaks at ~2.16 GiB, so `maxConcurrent > 1` + /// is an out-of-memory kill on a phone, not a performance note. + int _inFlight = 0; + int maxConcurrent = 0; + + /// Forces a real suspension inside the decode so a second caller has a + /// chance to overlap. + Duration delay = Duration.zero; + + _FakeDecoder({ + this.availability = ImageCodecAvailability.ready, + this.isBusy = false, + this.mode = 'ok', + Uint8List? png, + }) : png = png ?? Uint8List.fromList(List.filled(4096, 7)); + + @override + Future decodeBitstream({ + required Uint8List bitstream, + required AeicRatePoint ratePoint, + required int resolution, + }) async { + calls++; + _inFlight++; + if (_inFlight > maxConcurrent) maxConcurrent = _inFlight; + try { + return await _run(ratePoint, resolution); + } finally { + _inFlight--; + } + } + + Future _run(AeicRatePoint ratePoint, int resolution) async { + if (delay > Duration.zero) await Future.delayed(delay); + switch (mode) { + case 'null': + return null; + case 'throw': + throw StateError('decoder exploded'); + case 'fail': + return ImageCodecResult( + ratePoint: ratePoint, + resolution: resolution, + durationMs: 5, + status: ImageCodecStatus.failed, + ); + default: + return ImageCodecResult( + ratePoint: ratePoint, + resolution: resolution, + durationMs: 11, + status: ImageCodecStatus.completed, + pngBytes: png, + ); + } + } + + @override + void cancelCodecJob() => cancels++; +} + +const int kSender = 0x1a2b; +const int kSelf = 0xbeef; + +Uint8List _payload(int length, [int seed = 0]) { + return Uint8List.fromList( + List.generate(length, (i) => (i * 31 + seed) & 0xFF), + ); +} + +typedef _Rig = ({ + ReceivedImageStore store, + _FakeDecoder decoder, + InMemoryReceivedImageBlobStore blobs, + ImageReassembler reassembler, + DateTime Function() clock, + void Function(Duration) advance, +}); + +/// Builds a store wired to a fake decoder, an in-memory blob store and a +/// reassembler that all share one injected clock. +_Rig _build({ + int burstCap = 3, + int maxImages = 200, + int maxBytes = 64 * 1024 * 1024, + Duration maxAge = const Duration(days: 30), + _FakeDecoder? decoder, + InMemoryReceivedImageBlobStore? blobs, + bool processAutomatically = true, +}) { + var now = DateTime.utc(2026, 1, 1); + final dec = decoder ?? _FakeDecoder(); + final blobStore = blobs ?? InMemoryReceivedImageBlobStore(); + final reassembler = ImageReassembler(selfPrefix: kSelf, clock: () => now); + final store = ReceivedImageStore( + blobs: blobStore, + decoder: dec, + processAutomatically: processAutomatically, + burstCap: burstCap, + maxImages: maxImages, + maxBytes: maxBytes, + maxAge: maxAge, + clock: () => now, + ); + return ( + store: store, + decoder: dec, + blobs: blobStore, + reassembler: reassembler, + clock: () => now, + advance: (Duration d) => now = now.add(d), + ); +} + +void main() { + group('ReceivedImageRef', () { + test('round-trips a stream id', () { + final id = ReceivedImageRef.streamIdFor( + senderPrefix: 0x1a2b, + imgId: 7, + firstSeen: DateTime.fromMillisecondsSinceEpoch(0x693F21 * 1000), + ); + expect(id, '1a2b07' '00693f21'); + expect(id.length, 14); + expect(ReceivedImageRef.parse(ReceivedImageRef.encode(id)), id); + }); + + test('rejects everything that is not an image sentinel', () { + expect(ReceivedImageRef.parse('g:abc123'), isNull); + expect(ReceivedImageRef.parse('hello world'), isNull); + expect(ReceivedImageRef.parse('aeic:1:short'), isNull); + expect(ReceivedImageRef.parse('aeic:1:1A2B0700693F21'), isNull); + expect(ReceivedImageRef.parse('aeic:2:1a2b0700693f21'), isNull); + expect(ReceivedImageRef.parse('@[Bob] hi'), isNull); + expect(ReceivedImageRef.parse(' aeic:1:1a2b0700693f21 '), + '1a2b0700693f21'); + }); + }); + + group('intake and state machine', () { + test('one-chunk image goes receiving -> reassembled -> decoded', () async { + final h = _build(); + final payload = _payload(140); + final set = buildImageChunks( + payload: payload, + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 9, + ); + expect(set.dataChunkCount, 1); + + final outcome = h.reassembler.addChunk(set.blobs[0], channelIndex: 3); + final entry = await h.store.handleOutcome(outcome, channelIndex: 3); + expect(entry, isNotNull); + expect(entry!.state, ReceivedImageState.reassembled); + expect(entry.rate, AeicRatePoint.ft32); + expect(entry.metadataAssumed, isFalse); + expect(h.blobs.hasBitstream(entry.streamId), isTrue, + reason: 'bitstream must be written before the state changes'); + + await h.store.settle(); + final decoded = h.store.entryFor(entry.streamId)!; + expect(decoded.state, ReceivedImageState.decoded); + expect(decoded.synthesized, isTrue); + expect(decoded.decodeMs, 11); + expect(await h.store.ensurePng(entry.streamId), isNotNull); + expect(h.decoder.calls, 1); + }); + + test('two-chunk image reports 1 of 2 while incomplete', () async { + final h = _build(); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + expect(set.dataChunkCount, 2); + + final first = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + expect(first!.state, ReceivedImageState.receiving); + expect(first.receivedChunks, 1); + expect(first.totalChunks, 2); + + final second = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[1], channelIndex: 3), + channelIndex: 3, + ); + expect(second!.streamId, first.streamId, + reason: 'the sentinel must not change as chunks arrive'); + expect(second.state, ReceivedImageState.reassembled); + await h.store.settle(); + expect( + h.store.entryFor(first.streamId)!.state, + ReceivedImageState.decoded, + ); + }); + + test('duplicate delivery does not double-count chunks', () async { + final h = _build(); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + final first = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + // Same blob three more times (repeaters flood). + for (var i = 0; i < 3; i++) { + await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + } + final entry = h.store.entryFor(first!.streamId)!; + expect(entry.receivedChunks, 1); + expect(entry.state, ReceivedImageState.receiving); + expect(h.store.entries.length, 1); + }); + + test('parity chunks never count towards progress', () async { + final h = _build(); + // Three data chunks: chunk 0 + parity leaves one hole, so the image + // cannot complete and we can observe the progress count. + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + kImageChunkCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + expect(set.dataChunkCount, 3); + expect(set.hasParity, isTrue); + // Parity first, then chunk 0: 1 data chunk received, not 2. + await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs.last, channelIndex: 3), + channelIndex: 3, + ); + final entry = h.store.entries.single; + expect(entry.receivedChunks, 0); + final after = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + expect(after!.receivedChunks, 1); + expect(after.state, ReceivedImageState.receiving); + }); + + test('parity recovery still lands on decoded', () async { + final h = _build(); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + // Drop chunk 1, deliver 0 + parity. + await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + final entry = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs.last, channelIndex: 3), + channelIndex: 3, + ); + expect(entry!.state, ReceivedImageState.reassembled); + expect(entry.recoveredWithParity, isTrue); + await h.store.settle(); + expect(h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoded); + }); + + test('loopback and malformed blobs create nothing', () async { + final h = _build(); + final own = buildImageChunks( + payload: _payload(120), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSelf, + imgId: 1, + ); + await h.store.handleOutcome( + h.reassembler.addChunk(own.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + await h.store.handleOutcome( + h.reassembler.addChunk(Uint8List.fromList([1, 2]), channelIndex: 3), + channelIndex: 3, + ); + expect(h.store.entries, isEmpty); + }); + + test('TTL expiry moves a partial image to failedIncomplete', () async { + final h = _build(); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + final entry = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + h.advance(kImageReassemblyTtl + const Duration(seconds: 1)); + final failures = []; + final expiring = ImageReassembler( + selfPrefix: kSelf, + onFailed: failures.add, + ); + // Rebuild the failure the transport would emit for this stream. + failures.add( + ImageReassemblyFailure( + key: entry!.key, + total: 2, + receivedDataChunks: 1, + hadParity: false, + firstSeen: entry.firstSeen, + expiredAt: h.clock(), + ), + ); + expiring.clear(); + await h.store.handleFailure(failures.single); + final failed = h.store.entryFor(entry.streamId)!; + expect(failed.state, ReceivedImageState.failedIncomplete); + expect(failed.receivedChunks, 1); + expect(failed.totalChunks, 2); + expect(failed.canRetryDecode, isFalse); + }); + + test('a corrupt-payload failure reports corrupt, not incomplete', () async { + final h = _build(); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + final entry = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + await h.store.handleFailure( + ImageReassemblyFailure( + key: entry!.key, + total: 2, + receivedDataChunks: 2, + hadParity: true, + firstSeen: entry.firstSeen, + expiredAt: h.clock(), + reason: ImageReassemblyFailureReason.unsupportedFormat, + ), + ); + final failed = h.store.entryFor(entry.streamId)!; + expect(failed.state, ReceivedImageState.failedCorrupt); + expect(failed.pngStored, isFalse); + expect(await h.store.ensurePng(entry.streamId), isNull); + }); + }); + + group('decode outcomes never present as decoded', () { + test('decoder failure -> failedCorrupt and no pixels', () async { + final h = _build(decoder: _FakeDecoder(mode: 'fail')); + final entry = await _completeOne(h); + await h.store.settle(); + final after = h.store.entryFor(entry.streamId)!; + expect(after.state, ReceivedImageState.failedCorrupt); + expect(after.pngStored, isFalse); + expect(after.pngBytes, isNull); + expect(await h.store.ensurePng(entry.streamId), isNull); + // Retryable, because the bitstream survived. + expect(after.canRetryDecode, isTrue); + }); + + test('decoder throw -> failedCorrupt', () async { + final h = _build(decoder: _FakeDecoder(mode: 'throw')); + final entry = await _completeOne(h); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.failedCorrupt, + ); + }); + + test('decodeBitstream null -> decoderUnavailable', () async { + final h = _build(decoder: _FakeDecoder(mode: 'null')); + final entry = await _completeOne(h); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoderUnavailable, + ); + }); + + test('availability != ready at dequeue -> decoderUnavailable, and the ' + 'image decodes once the codec comes back', () async { + final decoder = _FakeDecoder( + availability: ImageCodecAvailability.disabled, + ); + final h = _build(decoder: decoder); + final entry = await _completeOne(h); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoderUnavailable, + ); + expect(decoder.calls, 0); + + decoder.availability = ImageCodecAvailability.ready; + h.store.notifyDecoderChanged(); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoded, + ); + }); + + test('a busy codec parks the queue instead of failing it', () async { + final decoder = _FakeDecoder(isBusy: true); + final h = _build(decoder: decoder); + final entry = await _completeOne(h); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.reassembled, + ); + expect(h.store.decodeQueue, [entry.streamId]); + expect(decoder.calls, 0); + + decoder.isBusy = false; + h.store.notifyDecoderChanged(); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoded, + ); + }); + + test('retry after failure re-runs the decoder', () async { + final decoder = _FakeDecoder(mode: 'fail'); + final h = _build(decoder: decoder); + final entry = await _completeOne(h); + await h.store.settle(); + decoder.mode = 'ok'; + await h.store.requestDecode(entry.streamId); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoded, + ); + expect(decoder.calls, 2); + }); + + test('failedIncomplete is never retryable', () async { + final h = _build(); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 4, + ); + final entry = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + await h.store.handleFailure( + ImageReassemblyFailure( + key: entry!.key, + total: 2, + receivedDataChunks: 1, + hadParity: false, + firstSeen: entry.firstSeen, + expiredAt: h.clock(), + ), + ); + await h.store.requestDecode(entry.streamId); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.failedIncomplete, + ); + expect(h.decoder.calls, 0); + }); + }); + + group('decode gates', () { + test('backgrounded: nothing decodes until the app resumes', () async { + final h = _build(); + h.store.setForeground(false); + final entry = await _completeOne(h); + await h.store.settle(); + expect(h.decoder.calls, 0); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.reassembled, + ); + h.store.setForeground(true); + await h.store.settle(); + expect( + h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoded, + ); + }); + + test('burst cap parks all but the newest few behind a tap', () async { + final h = _build(burstCap: 3); + h.store.setForeground(false); + final ids = []; + for (var i = 0; i < 5; i++) { + ids.add((await _completeOne(h, imgId: 20 + i, seed: i)).streamId); + h.advance(const Duration(seconds: 2)); + } + expect(h.store.decodeQueue.length, 3); + expect(h.store.entryFor(ids[0])!.needsManualDecode, isTrue); + expect(h.store.entryFor(ids[1])!.needsManualDecode, isTrue); + expect(h.store.entryFor(ids[4])!.needsManualDecode, isFalse); + + h.store.setForeground(true); + await h.store.settle(); + expect(h.decoder.calls, 3); + expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.reassembled); + expect(h.store.entryFor(ids[4])!.state, ReceivedImageState.decoded); + + // A tap on a parked image decodes it. + await h.store.requestDecode(ids[0]); + await h.store.settle(); + expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.decoded); + }); + + test('memory pressure cancels and parks, never fails', () async { + final h = _build(); + h.store.setForeground(false); + final entry = await _completeOne(h); + await h.store.handleMemoryPressure(); + expect(h.decoder.cancels, greaterThanOrEqualTo(1)); + expect(h.store.decodeQueue, isEmpty); + final after = h.store.entryFor(entry.streamId)!; + expect(after.state, ReceivedImageState.reassembled); + expect(after.needsManualDecode, isTrue); + }); + }); + + group('eviction', () { + test('byte budget drops the oldest PNG first and keeps the bitstream', + () async { + final decoder = _FakeDecoder( + png: Uint8List.fromList(List.filled(1000, 3)), + ); + final h = _build(decoder: decoder, maxBytes: 2500); + final ids = []; + for (var i = 0; i < 3; i++) { + final entry = await _completeOne(h, imgId: 40 + i, seed: i); + await h.store.settle(); + ids.add(entry.streamId); + h.advance(const Duration(seconds: 5)); + } + // 3 x 1000 B PNG > 2500 B, so the oldest PNG must have gone. + expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.evicted); + expect(h.store.entryFor(ids[0])!.pngStored, isFalse); + expect(h.blobs.hasPng(ids[0]), isFalse); + expect(h.blobs.hasBitstream(ids[0]), isTrue, + reason: 'a ~156 B bitstream is cheap; keep it so we can re-decode'); + expect(h.store.entryFor(ids[0])!.canRetryDecode, isTrue); + expect(h.store.entryFor(ids[2])!.state, ReceivedImageState.decoded); + expect(h.store.totalBytes, lessThanOrEqualTo(2500)); + + // "Decode again" works off the surviving bitstream, and the image we just + // decoded is never the budget's own victim (that would thrash forever). + await h.store.requestDecode(ids[0]); + await h.store.settle(); + expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.decoded); + expect(h.store.entryFor(ids[1])!.state, ReceivedImageState.evicted); + expect(h.store.totalBytes, lessThanOrEqualTo(2500)); + }); + + test('image-count budget evicts oldest first', () async { + final h = _build(maxImages: 2); + final ids = []; + for (var i = 0; i < 4; i++) { + final entry = await _completeOne(h, imgId: 60 + i, seed: i); + await h.store.settle(); + ids.add(entry.streamId); + h.advance(const Duration(seconds: 5)); + } + expect(h.store.storedImageCount, lessThanOrEqualTo(2)); + expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.evicted); + expect(h.store.entryFor(ids[1])!.state, ReceivedImageState.evicted); + expect(h.store.entryFor(ids[3])!.state, ReceivedImageState.decoded); + }); + + test('age budget removes both files', () async { + final h = _build(maxAge: const Duration(minutes: 10)); + final entry = await _completeOne(h); + await h.store.settle(); + expect(h.store.entryFor(entry.streamId)!.state, + ReceivedImageState.decoded); + h.advance(const Duration(minutes: 11)); + final evicted = await h.store.evictToBudget(); + expect(evicted, contains(entry.streamId)); + final after = h.store.entryFor(entry.streamId)!; + expect(after.state, ReceivedImageState.evicted); + expect(after.canRetryDecode, isFalse); + expect(h.blobs.hasBitstream(entry.streamId), isFalse); + expect(h.blobs.hasPng(entry.streamId), isFalse); + }); + + test('deleteImage reaps all three files', () async { + final h = _build(); + final entry = await _completeOne(h); + await h.store.settle(); + expect(h.blobs.hasSidecar(entry.streamId), isTrue); + await h.store.deleteImage(entry.streamId); + expect(h.store.entryFor(entry.streamId), isNull); + expect(h.blobs.hasSidecar(entry.streamId), isFalse); + expect(h.blobs.hasPng(entry.streamId), isFalse); + expect(h.blobs.hasBitstream(entry.streamId), isFalse); + }); + }); + + group('persistence and startup repair', () { + test('sidecars survive a restart and decoded images reload', () async { + final blobs = InMemoryReceivedImageBlobStore(); + final h = _build(blobs: blobs); + final entry = await _completeOne(h); + await h.store.settle(); + + final reborn = ReceivedImageStore( + blobs: blobs, + decoder: _FakeDecoder(), + clock: h.clock, + ); + await reborn.load(); + final loaded = reborn.entryFor(entry.streamId)!; + expect(loaded.state, ReceivedImageState.decoded); + expect(loaded.pngBytes, isNull, reason: 'pixels are read lazily'); + expect(await reborn.ensurePng(entry.streamId), isNotNull); + reborn.dispose(); + }); + + test('receiving and decoding are repaired on load', () async { + final blobs = InMemoryReceivedImageBlobStore(); + const receivingId = 'aaaa010000000001'; + await blobs.writeSidecar( + 'partial', + jsonEncode({ + 'streamId': 'partial00000001', + 'senderPrefix': 1, + 'imgId': 1, + 'channelIndex': 0, + 'firstSeenMs': DateTime.utc(2026, 1, 1).millisecondsSinceEpoch, + 'state': 'receiving', + 'receivedChunks': 1, + 'totalChunks': 2, + }), + ); + await blobs.writeSidecar( + 'midDecode', + jsonEncode({ + 'streamId': 'midDecode', + 'senderPrefix': 2, + 'imgId': 2, + 'channelIndex': 0, + 'firstSeenMs': DateTime.utc(2026, 1, 1).millisecondsSinceEpoch, + 'state': 'decoding', + 'receivedChunks': 1, + 'totalChunks': 1, + }), + ); + await blobs.writeBitstream('midDecode', _payload(150)); + await blobs.writeSidecar( + 'lostPng', + jsonEncode({ + 'streamId': 'lostPng', + 'senderPrefix': 3, + 'imgId': 3, + 'channelIndex': 0, + 'firstSeenMs': DateTime.utc(2026, 1, 1).millisecondsSinceEpoch, + 'state': 'decoded', + 'receivedChunks': 1, + 'totalChunks': 1, + }), + ); + expect(receivingId.length, 16); // sanity: not a valid sentinel id + + final store = ReceivedImageStore( + blobs: blobs, + decoder: _FakeDecoder(availability: ImageCodecAvailability.disabled), + clock: () => DateTime.utc(2026, 1, 1, 1), + ); + await store.load(); + expect( + store.entryFor('partial00000001')!.state, + ReceivedImageState.failedIncomplete, + ); + expect( + store.entryFor('midDecode')!.state, + ReceivedImageState.reassembled, + ); + expect(store.entryFor('lostPng')!.state, ReceivedImageState.evicted); + store.dispose(); + }); + }); + + group('outgoing', () { + test('registerOutgoing is decoded, not synthesized', () async { + final h = _build(); + final entry = await h.store.registerOutgoing( + channelIndex: 1, + senderPrefix: kSelf, + imgId: 3, + previewPng: Uint8List.fromList(List.filled(64, 1)), + rate: AeicRatePoint.ft32, + chunkCount: 2, + ); + expect(entry.state, ReceivedImageState.decoded); + expect(entry.isOutgoing, isTrue); + expect(entry.synthesized, isFalse); + expect(await h.store.ensurePng(entry.streamId), isNotNull); + expect(h.decoder.calls, 0); + }); + }); + + group('processAutomatically (tap to process)', () { + test('an arrival is parked, not decoded, and a tap still works', () async { + final h = _build(processAutomatically: false); + final entry = await _completeOne(h); + await h.store.settle(); + + expect(entry.state, ReceivedImageState.reassembled); + expect(entry.needsManualDecode, isTrue); + expect(h.store.decodeQueue, isEmpty); + expect(h.decoder.calls, 0, + reason: 'a radio packet must never start a 2.16 GiB decode by itself'); + // Everything the placeholder card needs is already on the entry. + expect(entry.senderPrefix, kSender); + expect(entry.bitstreamByteCount, greaterThan(0)); + expect(entry.receivedChunks, 1); + expect(entry.totalChunks, 1); + expect(h.blobs.hasBitstream(entry.streamId), isTrue); + + await h.store.requestDecode(entry.streamId); + await h.store.settle(); + final after = h.store.entryFor(entry.streamId)!; + expect(after.state, ReceivedImageState.decoded); + expect(after.needsManualDecode, isFalse); + expect(h.decoder.calls, 1); + }); + + test('a finished model download does not decode the whole backlog', + () async { + final decoder = _FakeDecoder( + availability: ImageCodecAvailability.disabled, + ); + final h = _build(decoder: decoder, processAutomatically: false); + final ids = []; + for (var i = 0; i < 4; i++) { + ids.add((await _completeOne(h, imgId: 80 + i, seed: i)).streamId); + h.advance(const Duration(seconds: 2)); + } + // The model lands. + decoder.availability = ImageCodecAvailability.ready; + h.store.notifyDecoderChanged(); + await h.store.settle(); + expect(decoder.calls, 0); + expect(h.store.decodeQueue, isEmpty); + for (final id in ids) { + expect(h.store.entryFor(id)!.state, ReceivedImageState.reassembled); + expect(h.store.entryFor(id)!.needsManualDecode, isTrue); + } + }); + + test('turning the setting on affects future arrivals only', () async { + final h = _build(processAutomatically: false); + final parked = await _completeOne(h, imgId: 90); + h.advance(const Duration(seconds: 2)); + + h.store.processAutomatically = true; + await h.store.settle(); + expect(h.decoder.calls, 0, reason: 'no retro-decode of the backlog'); + expect(h.store.entryFor(parked.streamId)!.state, + ReceivedImageState.reassembled); + + final fresh = await _completeOne(h, imgId: 91, seed: 5); + await h.store.settle(); + expect(h.store.entryFor(fresh.streamId)!.state, + ReceivedImageState.decoded); + expect(h.store.entryFor(parked.streamId)!.state, + ReceivedImageState.reassembled); + }); + + test('a reassembled entry restored from disk is always tappable', () async { + final blobs = InMemoryReceivedImageBlobStore(); + // Written by a session that had auto-processing ON. + final h = _build(blobs: blobs); + h.store.setForeground(false); + final entry = await _completeOne(h); + expect(entry.needsManualDecode, isFalse); + + final reborn = ReceivedImageStore( + blobs: blobs, + decoder: _FakeDecoder(), + processAutomatically: false, + clock: h.clock, + ); + await reborn.load(); + final loaded = reborn.entryFor(entry.streamId)!; + expect(loaded.state, ReceivedImageState.reassembled); + expect(loaded.needsManualDecode, isTrue); + expect(reborn.decodeQueue, isEmpty); + reborn.dispose(); + }); + + test('decoderAvailability is exposed for the tap target', () async { + final h = _build(); + expect(h.store.decoderAvailability, ImageCodecAvailability.ready); + h.decoder.availability = ImageCodecAvailability.disabled; + expect(h.store.decoderAvailability, ImageCodecAvailability.disabled); + final noDecoder = ReceivedImageStore(decoder: null); + expect( + noDecoder.decoderAvailability, + ImageCodecAvailability.unavailable, + ); + noDecoder.dispose(); + }); + }); + + group('decode concurrency', () { + test('ten taps run ten decodes strictly one at a time', () async { + final decoder = _FakeDecoder()..delay = const Duration(milliseconds: 5); + final h = _build(decoder: decoder, processAutomatically: false); + final ids = []; + for (var i = 0; i < 10; i++) { + ids.add((await _completeOne(h, imgId: 100 + i, seed: i)).streamId); + h.advance(const Duration(seconds: 2)); + } + // Fire every tap without awaiting: this is a user hammering the list. + final taps = >[ + for (final id in ids) h.store.requestDecode(id), + ]; + await Future.wait(taps); + await h.store.settle(); + + expect(decoder.calls, 10); + expect(decoder.maxConcurrent, 1, + reason: 'two concurrent decodes is ~4.3 GiB and an OOM kill'); + for (final id in ids) { + expect(h.store.entryFor(id)!.state, ReceivedImageState.decoded); + } + }); + + test('an arrival during a decode does not start a second one', () async { + final decoder = _FakeDecoder()..delay = const Duration(milliseconds: 5); + final h = _build(decoder: decoder); + await _completeOne(h, imgId: 120); + h.advance(const Duration(seconds: 2)); + // Do NOT settle: the first decode is still in flight. + await _completeOne(h, imgId: 121, seed: 1); + await h.store.settle(); + expect(decoder.calls, 2); + expect(decoder.maxConcurrent, 1); + }); + }); + + group('deletion hooks', () { + test('deleteImagesForChannel reclaims every image of one conversation', + () async { + final h = _build(); + final a = await _completeOne(h, imgId: 130); + h.advance(const Duration(seconds: 2)); + final b = await _completeOne(h, imgId: 131, seed: 1); + await h.store.settle(); + // A third image on another channel must survive. + final other = await h.store.registerOutgoing( + channelIndex: 9, + senderPrefix: kSelf, + imgId: 5, + previewPng: Uint8List.fromList(List.filled(32, 2)), + rate: AeicRatePoint.ft32, + chunkCount: 1, + ); + + final removed = await h.store.deleteImagesForChannel(3); + expect(removed, containsAll([a.streamId, b.streamId])); + expect(h.store.entryFor(a.streamId), isNull); + expect(h.store.entryFor(b.streamId), isNull); + expect(h.blobs.hasPng(a.streamId), isFalse); + expect(h.blobs.hasBitstream(a.streamId), isFalse); + expect(h.blobs.hasSidecar(a.streamId), isFalse); + expect(h.store.entryFor(other.streamId), isNotNull); + expect(h.store.totalBytes, other.storedBytes); + }); + + test('deleteImageForSentinel takes the message text', () async { + final h = _build(); + final entry = await _completeOne(h); + await h.store.settle(); + await h.store.deleteImageForSentinel('not an image'); + expect(h.store.entryFor(entry.streamId), isNotNull); + await h.store.deleteImageForSentinel( + ReceivedImageRef.encode(entry.streamId), + ); + expect(h.store.entryFor(entry.streamId), isNull); + expect(h.blobs.hasPng(entry.streamId), isFalse); + }); + }); + + group('FileReceivedImageBlobStore', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('aeic_blobs_test'); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + FileReceivedImageBlobStore newStore() => FileReceivedImageBlobStore( + baseDirectory: () async => tempDir, + ); + + test('round-trips bytes and sidecars through real files', () async { + final blobs = newStore(); + await blobs.writeBitstream('1a2b0700693f21', _payload(155)); + await blobs.writePng('1a2b0700693f21', _payload(4096, 3)); + await blobs.writeSidecar('1a2b0700693f21', '{"streamId":"x"}'); + + expect(await blobs.readBitstream('1a2b0700693f21'), _payload(155)); + expect(await blobs.readPng('1a2b0700693f21'), _payload(4096, 3)); + expect(await blobs.bitstreamSize('1a2b0700693f21'), 155); + expect(await blobs.pngSize('1a2b0700693f21'), 4096); + expect(await blobs.readSidecars(), {'1a2b0700693f21': '{"streamId":"x"}'}); + expect(blobs.pngPath('1a2b0700693f21'), endsWith('1a2b0700693f21.png')); + expect(File(blobs.pngPath('1a2b0700693f21')!).existsSync(), isTrue); + + // A fresh instance over the same directory sees the same bytes: this is + // the whole point of the class. + final second = newStore(); + expect(await second.readBitstream('1a2b0700693f21'), _payload(155)); + expect((await second.readSidecars()).keys, ['1a2b0700693f21']); + + await blobs.deletePng('1a2b0700693f21'); + await blobs.deleteBitstream('1a2b0700693f21'); + await blobs.deleteSidecar('1a2b0700693f21'); + expect(await blobs.readPng('1a2b0700693f21'), isNull); + expect(await blobs.pngSize('1a2b0700693f21'), isNull); + expect(await blobs.readSidecars(), isEmpty); + // Deletes are idempotent. + await blobs.deletePng('1a2b0700693f21'); + }); + + test('missing files read as null and unsafe ids are refused', () async { + final blobs = newStore(); + expect(await blobs.readPng('deadbeefdeadbe'), isNull); + expect(await blobs.bitstreamSize('deadbeefdeadbe'), isNull); + await blobs.writePng('../../escape', _payload(8)); + expect(blobs.pngPath('../../escape'), isNull); + await blobs.ensureReady(); + expect( + Directory(tempDir.path).listSync(recursive: true).length, + 1, + reason: 'only the received_images directory itself', + ); + }); + + test('readSidecars reaps orphaned bytes and stale tmp files', () async { + final blobs = newStore(); + final dir = await blobs.ensureReady(); + // A crash between "write bitstream" and "write sidecar". + await blobs.writeBitstream('orphan00000001', _payload(150)); + await blobs.writePng('orphan00000001', _payload(64)); + // A crash mid sidecar write. + await File('$dir/halfwrit0000001.json.tmp').writeAsString('{"a":'); + await blobs.writeSidecar('keeper000000001', '{"streamId":"keeper"}'); + await blobs.writeBitstream('keeper000000001', _payload(150)); + + final sidecars = await blobs.readSidecars(); + expect(sidecars.keys, ['keeper000000001']); + expect(await blobs.readBitstream('orphan00000001'), isNull); + expect(await blobs.readPng('orphan00000001'), isNull); + expect(File('$dir/halfwrit0000001.json.tmp').existsSync(), isFalse); + expect(await blobs.readBitstream('keeper000000001'), isNotNull); + }); + + test('a truncated sidecar is skipped, not surfaced as an entry', () async { + final blobs = newStore(); + final dir = await blobs.ensureReady(); + await File('$dir/broken000000001.json').writeAsString('{"streamId":'); + expect(await blobs.readSidecars(), isEmpty); + }); + + test('a received image survives a restart end to end', () async { + var now = DateTime.utc(2026, 1, 1); + final reassembler = ImageReassembler(selfPrefix: kSelf, clock: () => now); + final store = ReceivedImageStore( + blobs: newStore(), + decoder: _FakeDecoder(), + clock: () => now, + ); + final set = buildImageChunks( + payload: _payload(140), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 12, + ); + final entry = await store.handleOutcome( + reassembler.addChunk(set.blobs[0], channelIndex: 3, now: now), + channelIndex: 3, + at: now, + ); + await store.settle(); + expect(store.entryFor(entry!.streamId)!.state, + ReceivedImageState.decoded); + store.dispose(); + + final reborn = ReceivedImageStore( + blobs: newStore(), + decoder: _FakeDecoder(), + clock: () => now, + ); + await reborn.load(); + final loaded = reborn.entryFor(entry.streamId); + expect(loaded, isNotNull, + reason: 'the whole point: images outlive the process'); + expect(loaded!.state, ReceivedImageState.decoded); + expect(loaded.pngStored, isTrue); + expect(loaded.pngBytes, isNull, reason: 'pixels are read lazily'); + expect(loaded.bitstreamByteCount, greaterThan(0)); + expect(await reborn.ensurePng(entry.streamId), isNotNull); + expect(reborn.pngPath(entry.streamId), isNotNull); + reborn.dispose(); + }); + + test('the age budget deletes the real files', () async { + var now = DateTime.utc(2026, 1, 1); + final blobs = newStore(); + final reassembler = ImageReassembler(selfPrefix: kSelf, clock: () => now); + final store = ReceivedImageStore( + blobs: blobs, + decoder: _FakeDecoder(), + maxAge: const Duration(minutes: 10), + clock: () => now, + ); + final set = buildImageChunks( + payload: _payload(140), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 13, + ); + final entry = await store.handleOutcome( + reassembler.addChunk(set.blobs[0], channelIndex: 3, now: now), + channelIndex: 3, + at: now, + ); + await store.settle(); + final png = blobs.pngPath(entry!.streamId)!; + expect(File(png).existsSync(), isTrue); + now = now.add(const Duration(minutes: 11)); + await store.evictToBudget(); + expect(File(png).existsSync(), isFalse); + expect(await blobs.readBitstream(entry.streamId), isNull); + store.dispose(); + }); + }); + + test('listeners fire for the message list and for the single bubble', + () async { + final h = _build(); + var storeNotifications = 0; + h.store.addListener(() => storeNotifications++); + final set = buildImageChunks( + payload: _payload(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: 77, + ); + final first = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3), + channelIndex: 3, + ); + final listenable = h.store.listenableFor(first!.streamId); + var bubbleNotifications = 0; + listenable.addListener(() => bubbleNotifications++); + await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[1], channelIndex: 3), + channelIndex: 3, + ); + await h.store.settle(); + expect(storeNotifications, greaterThan(1)); + expect(bubbleNotifications, greaterThan(1)); + expect(listenable.value!.state, ReceivedImageState.decoded); + }); +} + +/// Delivers a whole single-chunk image and returns its entry (state +/// `reassembled`; the caller decides whether to `settle()`). +Future _completeOne( + _Rig h, { + int imgId = 11, + int seed = 0, +}) async { + final set = buildImageChunks( + payload: _payload(140, seed), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: kSender, + imgId: imgId, + ); + final entry = await h.store.handleOutcome( + h.reassembler.addChunk(set.blobs[0], channelIndex: 3, now: h.clock()), + channelIndex: 3, + at: h.clock(), + ); + return entry!; +} diff --git a/test/widgets/image_send_preview_sheet_test.dart b/test/widgets/image_send_preview_sheet_test.dart new file mode 100644 index 00000000..e50ec8ac --- /dev/null +++ b/test/widgets/image_send_preview_sheet_test.dart @@ -0,0 +1,331 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/utils/lora_airtime.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; +import 'package:meshcore_open/widgets/image_send_preview_sheet.dart'; + +/// A real 1x1 PNG. Image.memory reports decode failures through FlutterError, +/// which fails the test even though the sheet has an errorBuilder, so the test +/// must hand it bytes that actually decode. +final Uint8List _onePixelPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/' + 'q842iQAAAABJRU5ErkJggg==', +); + +/// Radio settings a connected device would report: SF10 / BW 250k / CR 4/5. +/// The coding rate is given in the raw 1..4 firmware domain on purpose, to keep +/// the sheet's normalisation on the tested path. +const ImageSendRadio _knownRadio = ImageSendRadio( + spreadingFactor: 10, + bandwidthHz: 250000, + rawCodingRate: 1, +); + +/// Packets the sheet must report for the fake codec's ft32 mean payload: +/// the chunker's own data-chunk count plus the XOR parity packet, which is on by +/// default. Derived, because the chunk capacities have moved before. +final int _expectedPackets = + imageChunkCount(ImageCodecRateStats.standard.meanBytes) + 1; + +/// Nothing known yet — the state right after connecting, before SELF_INFO. +const ImageSendRadio _unknownRadio = ImageSendRadio(); + +Future _openSheet( + WidgetTester tester, { + required ImageSendRadio radio, + ImageSendCodec codec = const FakeImageSendCodec(latency: Duration.zero), +}) async { + ImageSendPreviewResult? result; + var closed = false; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + result = await showImageSendPreviewSheet( + context: context, + imageBytes: _onePixelPng, + originalFileBytes: 2 * 1024 * 1024, + codec: codec, + radio: radio, + ); + closed = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(closed, isFalse, reason: 'sheet should still be open'); + return result; +} + +/// Every string rendered by the sheet, so assertions can look for a value +/// without knowing which widget carries it. +List _texts(WidgetTester tester) => tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .whereType() + .toList(); + +void main() { + testWidgets('renders with a fake codec and shows the image preview', + (tester) async { + await _openSheet(tester, radio: _knownRadio); + + expect(find.byType(ImageSendPreviewSheet), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + // The send action is enabled once the encode has settled. + final send = tester.widget(find.byType(FilledButton)); + expect(send.onPressed, isNotNull); + }); + + testWidgets('shows the packet count for the encoded ft32 payload', + (tester) async { + await _openSheet(tester, radio: _knownRadio); + + // FakeImageSendCodec emits the measured ft32 mean (156 B) -> one data chunk, + // plus the XOR parity packet, which is on by default. + final expected = estimateSendFromRadioParams( + payloadBytes: ImageCodecRateStats.standard.meanBytes, + spreadingFactor: 10, + bandwidthHz: 250000, + codingRate: 1, + ); + expect(expected.chunkCount, inInclusiveRange(2, 3)); + expect(_texts(tester), contains('${expected.chunkCount}')); + }); + + testWidgets('shows a concrete airtime when the radio settings are known', + (tester) async { + await _openSheet(tester, radio: _knownRadio); + + final texts = _texts(tester); + expect( + texts, + isNot(contains('—')), + reason: 'a connected radio must not render the unknown placeholder', + ); + // The headline time figure is formatted as " s" by the sheet. + expect( + texts.any((t) => RegExp(r'^\d+(\.\d)? s$').hasMatch(t)), + isTrue, + reason: 'expected a seconds figure among: $texts', + ); + }); + + testWidgets('headline time is the paced wall clock, not raw airtime', + (tester) async { + await _openSheet(tester, radio: _knownRadio); + + final expected = estimateSendFromRadioParams( + payloadBytes: ImageCodecRateStats.standard.meanBytes, + spreadingFactor: 10, + bandwidthHz: 250000, + codingRate: 1, + ); + // Two packets, so pacing must have widened the figure. + expect( + expected.pacedWallClock!.inMicroseconds, + greaterThan(expected.totalAirtime!.inMicroseconds), + ); + + String seconds(Duration d) { + final total = d.inMilliseconds / 1000.0; + return total < 10 ? total.toStringAsFixed(1) : total.round().toString(); + } + + final texts = _texts(tester); + expect(texts, contains('${seconds(expected.pacedWallClock!)} s')); + expect(texts, isNot(contains('${seconds(expected.totalAirtime!)} s'))); + }); + + testWidgets('renders "unknown" airtime when the radio params are absent', + (tester) async { + await _openSheet(tester, radio: _unknownRadio); + + final texts = _texts(tester); + // The em dash placeholder, never a fabricated duration. + expect(texts, contains('—')); + expect( + texts.any((t) => RegExp(r'^\d+(\.\d)? s$').hasMatch(t)), + isFalse, + reason: 'no duration may be invented; got: $texts', + ); + // The packet count is still meaningful and must survive. + expect(texts, contains('$_expectedPackets')); + }); + + testWidgets('offers no quality selector', (tester) async { + await _openSheet(tester, radio: _knownRadio); + + final texts = _texts(tester); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(texts, isNot(contains(l10n.imageSend_quality))); + expect(texts, isNot(contains(l10n.imageSend_qualityStandard))); + expect(texts, isNot(contains(l10n.imageSend_qualityHigh))); + expect(find.byIcon(Icons.radio_button_checked), findsNothing); + expect(find.byIcon(Icons.radio_button_unchecked), findsNothing); + }); + + testWidgets('returns null when the user cancels', (tester) async { + ImageSendPreviewResult? result; + var closed = false; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + result = await showImageSendPreviewSheet( + context: context, + imageBytes: _onePixelPng, + originalFileBytes: 1024, + codec: const FakeImageSendCodec(latency: Duration.zero), + radio: _knownRadio, + ); + closed = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + + expect(closed, isTrue); + expect(result, isNull); + expect(find.byType(ImageSendPreviewSheet), findsNothing); + }); + + testWidgets('confirming returns the payload, packet count and both times', + (tester) async { + ImageSendPreviewResult? result; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + result = await showImageSendPreviewSheet( + context: context, + imageBytes: _onePixelPng, + originalFileBytes: 1024, + codec: const FakeImageSendCodec(latency: Duration.zero), + radio: _knownRadio, + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.payload.length, ImageCodecRateStats.standard.meanBytes); + // ft32 is the only rate point the sheet can produce. + expect(result!.rate, kImageSendRatePoint); + expect(result!.includeParity, isTrue); + expect(result!.packetCount, _expectedPackets); + expect(result!.airtime, isNotNull); + expect( + result!.wallClock!.inMicroseconds, + greaterThan(result!.airtime!.inMicroseconds), + ); + }); + + testWidgets('a codec that is still downloading cannot be sent from', + (tester) async { + await _openSheet( + tester, + radio: _knownRadio, + codec: const FakeImageSendCodec( + availability: ImageCodecAvailability.downloading, + latency: Duration.zero, + ), + ); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(_texts(tester), contains(l10n.imageSend_codecDownloading)); + final send = tester.widget(find.byType(FilledButton)); + expect(send.onPressed, isNull); + }); + + testWidgets('an unavailable codec explains itself with unavailableReason, ' + 'not the generic string', (tester) async { + const reason = 'This build ships the image decoder only. Encoding and ' + 'decoding a bitstream also needs the entropy-side graph and the rANS ' + 'coder, which are not included yet.'; + await _openSheet( + tester, + radio: _knownRadio, + codec: const FakeImageSendCodec( + availability: ImageCodecAvailability.unavailable, + unavailableReason: reason, + latency: Duration.zero, + ), + ); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + final texts = _texts(tester); + expect(texts, contains(reason)); + expect( + texts, + isNot(contains(l10n.imageSend_codecUnavailable)), + reason: 'the concrete reason must REPLACE the generic sentence', + ); + final send = tester.widget(find.byType(FilledButton)); + expect(send.onPressed, isNull); + }); + + testWidgets('an unavailable codec that gives no reason falls back to the ' + 'generic string', (tester) async { + await _openSheet( + tester, + radio: _knownRadio, + codec: const FakeImageSendCodec( + availability: ImageCodecAvailability.unavailable, + latency: Duration.zero, + ), + ); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(_texts(tester), contains(l10n.imageSend_codecUnavailable)); + }); +} diff --git a/test/widgets/received_image_message_test.dart b/test/widgets/received_image_message_test.dart new file mode 100644 index 00000000..4a819997 --- /dev/null +++ b/test/widgets/received_image_message_test.dart @@ -0,0 +1,497 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/image_codec_support.dart'; +import 'package:meshcore_open/services/image_chunk_transport.dart'; +import 'package:meshcore_open/widgets/image_send_codec_binding.dart'; +import 'package:meshcore_open/services/received_image_store.dart'; +import 'package:meshcore_open/widgets/received_image_message.dart'; + +const String _png1x1 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=='; + +/// A decoder seam whose availability the test controls. +/// +/// [decodeBitstream] never returns a picture: every tap-routing test only cares +/// about which of the two branches the bubble took, and a fake that produced +/// pixels would drag PNG decoding into an assertion about routing. +class _FakeDecoder implements ReceivedImageDecoder { + @override + final ImageCodecAvailability availability; + + @override + bool isBusy = false; + + int decodeCalls = 0; + int cancelCalls = 0; + + _FakeDecoder(this.availability); + + @override + Future decodeBitstream({ + required Uint8List bitstream, + required AeicRatePoint ratePoint, + required int resolution, + }) async { + decodeCalls++; + return null; + } + + @override + void cancelCodecJob() => cancelCalls++; +} + +/// Builds a store holding one entry that is `reassembled + needsManualDecode`, +/// i.e. the "bitstream complete, waiting for the user" placeholder state. +Future _awaitingStore({ + ReceivedImageDecoder? decoder, + int bytes = 156, + int packets = 2, +}) async { + final blobs = InMemoryReceivedImageBlobStore(); + await blobs.writeBitstream('await1', Uint8List(bytes)); + await blobs.writeSidecar( + 'await1', + jsonEncode({ + 'streamId': 'await1', + 'senderPrefix': 0x1a2b, + 'imgId': 7, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': 'reassembled', + 'receivedChunks': packets, + 'totalChunks': packets, + 'needsManualDecode': true, + 'bitstreamStored': true, + 'bitstreamByteCount': bytes, + }), + ); + final store = ReceivedImageStore(blobs: blobs, decoder: decoder); + await store.load(); + return store; +} + +Future _pumpBubble( + WidgetTester tester, + ReceivedImageStore store, { + String streamId = 'await1', + VoidCallback? onOpenCodecSettings, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReceivedImageMessage( + streamId: streamId, + isOutgoing: false, + fallbackTextColor: Colors.black, + store: store, + onOpenCodecSettings: onOpenCodecSettings, + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('decoded incoming bubble carries the R6 badge and caption', + (tester) async { + final blobs = InMemoryReceivedImageBlobStore(); + final store = ReceivedImageStore(blobs: blobs); + final png = base64Decode(_png1x1); + final entry = await store.registerOutgoing( + channelIndex: 0, + senderPrefix: 1, + imgId: 1, + previewPng: Uint8List.fromList(png), + rate: AeicRatePoint.ft32, + chunkCount: 1, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReceivedImageMessage( + streamId: entry.streamId, + isOutgoing: true, + fallbackTextColor: Colors.black, + store: store, + ), + ), + ), + ); + await tester.pump(); + // Outgoing: real crop, so NO label. + expect(find.text('AI-reconstructed'), findsNothing); + + // Now an incoming decoded entry. + final blobs2 = InMemoryReceivedImageBlobStore(); + await blobs2.writeSidecar( + 'incoming', + jsonEncode({ + 'streamId': 'incoming', + 'senderPrefix': 2, + 'imgId': 2, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': 'decoded', + 'receivedChunks': 1, + 'totalChunks': 1, + }), + ); + await blobs2.writePng('incoming', Uint8List.fromList(png)); + final store2 = ReceivedImageStore(blobs: blobs2); + await store2.load(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReceivedImageMessage( + streamId: 'incoming', + isOutgoing: false, + fallbackTextColor: Colors.black, + store: store2, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.text('AI-reconstructed'), findsOneWidget); + expect(find.textContaining('Fine detail is generated'), findsOneWidget); + }); + + + testWidgets('receiving state shows the packet count', (tester) async { + final store = ReceivedImageStore(decoder: null); + final reassembler = ImageReassembler(selfPrefix: 0xbeef); + final set = buildImageChunks( + payload: Uint8List(kImageChunkFirstCapacity + 1), + metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard), + senderPrefix: 0x1a2b, + imgId: 5, + ); + final entry = await store.handleOutcome( + reassembler.addChunk(set.blobs[0], channelIndex: 0), + channelIndex: 0, + ); + expect(entry!.state, ReceivedImageState.receiving); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReceivedImageMessage( + streamId: entry.streamId, + isOutgoing: false, + fallbackTextColor: Colors.black, + store: store, + ), + ), + ), + ); + expect(find.text('1 of 2 packets'), findsOneWidget); + expect(find.text('AI-reconstructed'), findsNothing); + }); + + testWidgets( + 'awaiting card shows the bitstream size, the packet count and the ' + 'tap-to-process affordance', (tester) async { + final store = await _awaitingStore(bytes: 156, packets: 2); + final entry = store.entryFor('await1')!; + expect(entry.state, ReceivedImageState.reassembled); + expect(entry.needsManualDecode, isTrue); + + await _pumpBubble(tester, store); + + expect(find.text('156 bytes · 2 packets'), findsOneWidget); + expect(find.text('Tap to process'), findsOneWidget); + // A placeholder is never dressed up as a picture. + expect(find.text('AI-reconstructed'), findsNothing); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('awaiting card honours injected strings', (tester) async { + final store = await _awaitingStore(bytes: 209, packets: 3); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReceivedImageMessage( + streamId: 'await1', + isOutgoing: false, + fallbackTextColor: Colors.black, + store: store, + strings: ReceivedImageStrings( + incoming: (r, t) => 'in $r/$t', + queued: 'queued', + tapToDecode: 'tap decode', + awaiting: (b, p) => 'LOC $b B in $p pkts', + tapToProcess: 'LOC process', + decoding: 'decoding', + incomplete: (r, t) => 'incomplete $r/$t', + corrupt: 'corrupt', + decoderMissing: 'missing', + evicted: 'evicted', + retry: 'retry', + decodeAgain: 'again', + openSettings: 'settings', + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('LOC 209 B in 3 pkts'), findsOneWidget); + expect(find.text('LOC process'), findsOneWidget); + }); + + testWidgets('tap with a ready codec requests a decode and does NOT open ' + 'settings', (tester) async { + final decoder = _FakeDecoder(ImageCodecAvailability.ready); + final store = await _awaitingStore(decoder: decoder); + var settingsOpened = 0; + + await _pumpBubble( + tester, + store, + onOpenCodecSettings: () => settingsOpened++, + ); + + await tester.tap(find.text('Tap to process')); + await tester.pump(); + await tester.pump(); + + expect(settingsOpened, 0); + expect(decoder.decodeCalls, 1); + }); + + testWidgets('tap with no model installed opens the image-messages setting ' + 'and never asks the store to decode', (tester) async { + final decoder = _FakeDecoder(ImageCodecAvailability.disabled); + final store = await _awaitingStore(decoder: decoder); + var settingsOpened = 0; + + await _pumpBubble( + tester, + store, + onOpenCodecSettings: () => settingsOpened++, + ); + + await tester.tap(find.text('Tap to process')); + await tester.pump(); + await tester.pump(); + + expect(settingsOpened, 1); + expect(decoder.decodeCalls, 0); + // The entry is untouched: it stays tappable rather than falling into + // `decoderUnavailable` behind the user's back. + expect(store.entryFor('await1')!.state, ReceivedImageState.reassembled); + }); + + testWidgets('tap with no codec and no settings route still asks the store, ' + 'so the card is never inert', (tester) async { + final store = await _awaitingStore(decoder: null); + + await _pumpBubble(tester, store); + await tester.tap(find.text('Tap to process')); + await tester.pump(); + await tester.pump(); + + // No decoder at all -> the store parks it as decoderUnavailable, which is + // the state that renders the "Set up" body. + expect( + store.entryFor('await1')!.state, + ReceivedImageState.decoderUnavailable, + ); + }); + + testWidgets('a queued (auto-decode) card is still tappable', (tester) async { + final blobs = InMemoryReceivedImageBlobStore(); + await blobs.writeBitstream('queued1', Uint8List(120)); + await blobs.writeSidecar( + 'queued1', + jsonEncode({ + 'streamId': 'queued1', + 'senderPrefix': 3, + 'imgId': 3, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': 'reassembled', + 'receivedChunks': 1, + 'totalChunks': 1, + 'needsManualDecode': false, + 'bitstreamStored': true, + 'bitstreamByteCount': 120, + }), + ); + final decoder = _FakeDecoder(ImageCodecAvailability.ready); + final store = ReceivedImageStore(blobs: blobs, decoder: decoder); + await store.load(); + + await _pumpBubble(tester, store, streamId: 'queued1'); + expect(find.text('Waiting to decode'), findsOneWidget); + + // load() does not enqueue, so without a tap target this card would sit on + // "Waiting to decode" forever. + await tester.tap(find.text('Waiting to decode')); + await tester.pump(); + await tester.pump(); + expect(decoder.decodeCalls, 1); + }); + + testWidgets('every non-decoded state renders a legible label', + (tester) async { + const cases = { + 'failedIncomplete': 'Image incomplete — 1 of 3 packets arrived', + 'failedCorrupt': 'Image could not be reconstructed', + 'decoderUnavailable': 'Image received — image decoding is off', + 'evicted': 'Image no longer stored', + 'decoding': 'Reconstructing… about 1 s', + }; + for (final entry in cases.entries) { + // A distinct id per case on purpose: the ListView-recycling guard in + // _ReceivedImageMessageState only re-resolves the store when the + // streamId changes, so reusing one id would keep showing the first case. + final id = 'st_${entry.key}'; + final blobs = InMemoryReceivedImageBlobStore(); + await blobs.writeSidecar( + id, + jsonEncode({ + 'streamId': id, + 'senderPrefix': 4, + 'imgId': 4, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': entry.key, + 'receivedChunks': 1, + 'totalChunks': 3, + }), + ); + final store = ReceivedImageStore(blobs: blobs, decoder: null); + await store.load(); + await _pumpBubble(tester, store, streamId: id); + // `decoding` is repaired to failedIncomplete on load (no bitstream on + // disk), which is exactly the label a user must see after a crash. + final expected = entry.key == 'decoding' + ? 'Image incomplete — 1 of 3 packets arrived' + : entry.value; + expect( + find.text(expected), + findsOneWidget, + reason: 'state ${entry.key} rendered nothing legible', + ); + } + }); + + testWidgets('a decoded incoming image always shows the label, whatever ' + 'strings the caller injects', (tester) async { + final blobs = InMemoryReceivedImageBlobStore(); + final png = Uint8List.fromList(base64Decode(_png1x1)); + await blobs.writePng('dec', png); + await blobs.writeSidecar( + 'dec', + jsonEncode({ + 'streamId': 'dec', + 'senderPrefix': 9, + 'imgId': 9, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': 'decoded', + 'receivedChunks': 1, + 'totalChunks': 1, + 'pngStored': true, + 'pngByteCount': 68, + }), + ); + final store = ReceivedImageStore(blobs: blobs, decoder: null); + await store.load(); + + await _pumpBubble(tester, store, streamId: 'dec'); + await tester.pump(); + + expect(find.text('AI-reconstructed'), findsOneWidget); + expect(find.textContaining('Reconstructed by an AI model'), findsOneWidget); + expect(find.textContaining('Fine detail is generated'), findsOneWidget); + }); + + testWidgets('the caption quotes the real bitstream size, not a nominal one', + (tester) async { + // It used to hardcode "~156 bytes" under every image, so a 209-byte image + // and a 110-byte one both claimed 156. The label's whole purpose is + // honesty about what was actually transmitted. + for (final bytes in [110, 209]) { + final id = 'sz$bytes'; + final blobs = InMemoryReceivedImageBlobStore(); + await blobs.writePng(id, Uint8List.fromList(base64Decode(_png1x1))); + // A real bitstream on disk: load() derives the size by stat rather than + // trusting the sidecar, which is why this must actually exist. + await blobs.writeBitstream(id, Uint8List(bytes)); + await blobs.writeSidecar( + id, + jsonEncode({ + 'streamId': id, + 'senderPrefix': 9, + 'imgId': 9, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': 'decoded', + 'receivedChunks': 1, + 'totalChunks': 1, + 'pngStored': true, + 'pngByteCount': 68, + 'bitstreamStored': true, + 'bitstreamByteCount': bytes, + }), + ); + final store = ReceivedImageStore(blobs: blobs, decoder: null); + await store.load(); + + await _pumpBubble(tester, store, streamId: id); + await tester.pump(); + + expect(find.textContaining('$bytes bytes'), findsOneWidget, + reason: 'caption must quote $bytes'); + expect(find.textContaining('156 bytes'), findsNothing, + reason: 'no nominal size may leak into the caption'); + } + }); + + testWidgets('the R6 caption is never truncated in a narrow bubble', + (tester) async { + // It used to be maxLines: 2 and clipped mid-sentence — the rendered text + // read "Details are invented. Not a", cutting off exactly where the + // warning was. A half-shown warning is worse than none. + final blobs = InMemoryReceivedImageBlobStore(); + await blobs.writePng('clip', Uint8List.fromList(base64Decode(_png1x1))); + await blobs.writeBitstream('clip', Uint8List(209)); + await blobs.writeSidecar( + 'clip', + jsonEncode({ + 'streamId': 'clip', + 'senderPrefix': 9, + 'imgId': 9, + 'channelIndex': 0, + 'firstSeenMs': DateTime.now().millisecondsSinceEpoch, + 'state': 'decoded', + 'receivedChunks': 1, + 'totalChunks': 1, + 'pngStored': true, + 'pngByteCount': 68, + 'bitstreamStored': true, + 'bitstreamByteCount': 209, + }), + ); + final store = ReceivedImageStore(blobs: blobs, decoder: null); + await store.load(); + + await _pumpBubble(tester, store, streamId: 'clip'); + await tester.pump(); + + final caption = tester.widget( + find.textContaining('Reconstructed by an AI model'), + ); + expect(caption.maxLines, isNull, reason: 'the sentence must wrap in full'); + expect(caption.overflow, isNot(TextOverflow.ellipsis)); + // And the tail of the sentence is actually present. + expect(find.textContaining('not transmitted'), findsOneWidget); + }); +} diff --git a/tools/aeic/README.md b/tools/aeic/README.md new file mode 100644 index 00000000..677fd7d3 --- /dev/null +++ b/tools/aeic/README.md @@ -0,0 +1,47 @@ +# Regenerating the AEIC test fixtures + +The image codec's tests are pinned against data produced by the reference +implementation, not by the Dart code. That is the whole point: a Dart encoder +and a Dart decoder that agree with each other prove nothing, because they would +agree just as happily on a wrong wire format. These scripts produce the data the +Dart side is checked against. + +They are committed here so `test/services/golden/` is reproducible. Nothing in +the app runs them. + +| script | produces | used by | +|---|---|---| +| `export_golden.py` | `golden/aeic_cdf_ft32.bin`, `golden/vectors/*.gv` | `rans_coder_test.dart`, `entropy_tables_test.dart` | +| `record_entropy_io.py` | `golden/e2e/*.aeicrec`, `golden/e2e/manifest.json` | `image_codec_e2e_test.dart` | + +## What they need + +Neither script is self-contained. Both drive the real AEIC model, so they need +the research checkout that is **not** part of this repository: + +- the AEIC source (`github.com/LuizScarlet/AEIC`) with the two local patches: + pybind11 bumped to v2.13.6 (2.10.4 silently returns stride-0 arrays under + NumPy 2), and `.contiguous()` on `z_hat` in both `compress()` and + `decompress()` (the encoder and decoder otherwise land in different memory + layouts, and the ~2.8e-7 drift that causes desynchronises the rANS decoder on + roughly one image in 26, with no error raised) +- the `AEIC_SE_ft32.pkl` checkpoint +- the compiled C++ rANS extension (`src/cpp`, CMake) — the golden bitstreams are + produced by it, which is what makes them worth comparing against +- PyTorch, onnxruntime, NumPy 2 + +## Running them + +```bash +cd +AEIC_DEVICE=cpu python export_golden.py # tables + symbol vectors +AEIC_DEVICE=cpu python record_entropy_io.py # ONNX I/O recordings +``` + +Then copy the output into `test/services/golden/`. + +## If you change the wire format + +Regenerate. The fixtures encode the chunk framing and the metadata byte layout, +so a format change makes them stale in a way the tests will report as a codec +bug — which is the correct behaviour, but only if you know to look here. diff --git a/tools/aeic/export_golden.py b/tools/aeic/export_golden.py new file mode 100644 index 00000000..8351d767 --- /dev/null +++ b/tools/aeic/export_golden.py @@ -0,0 +1,819 @@ +"""P1 -- export the AEIC ft32 entropy tables and golden rANS vectors for the Dart port. + +Produces three things under results/golden/: + + 1. `aeic_cdf_ft32.bin` -- the static entropy-coder tables (both CDF groups) in a + compact little-endian binary container. This file SHIPS in the download bundle. + Format documented in `results/rans_port_spec.md` (section "CDF table file"). + + 2. `vectors/.gv` -- the exact int16 symbol and index arrays handed to the + C++ rANS encoder, in a compact binary container. + + 3. `vectors/.bin` -- the exact bitstream bytes the C++ coder produced for + that image, plus `.json` with sizes/hashes/container split. + +Every bitstream is written to disk and then `os.stat(path).st_size` is asserted equal +to `len(stream)`, so byte counts are auditable by stat rather than by a Python int. + +Run: + PYTORCH_ENABLE_MPS_FALLBACK=1 AEIC_DEVICE=cpu .venv/bin/python exp/export_golden.py + ... --all # all 26 images instead of the default 10 + ... --no-verify # skip the in-process decode round-trip (faster) +""" +import argparse +import hashlib +import json +import math +import os +import struct +import sys +from pathlib import Path + +os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") +os.environ.setdefault("AEIC_DEVICE", "cpu") + +import numpy as np +import torch +import torch.nn.functional as F + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +sys.path.insert(0, str(HERE)) + +import aeic_runner as R # noqa: E402 + +OUT = ROOT / "results" / "golden" +VEC = OUT / "vectors" + +# default corpus: 8 Kodak + the 2 custom images +DEFAULT_IMAGES = [ + "kodim01", "kodim02", "kodim05", "kodim08", + "kodim13", "kodim19", "kodim23", "kodim24", + "image2", "images", +] + +TABLE_MAGIC = b"AEICCDF\x01" # 8 bytes +GV_MAGIC = b"AEICGV\x00\x01" # 8 bytes +FORMAT_VERSION = 1 +PRECISION = 16 # rANS probability precision, bits +BYPASS_PRECISION = 2 +STREAM_PARTS = 2 # EntropyCoder() -> RansEncoder(ec_thread, 2) + +DT_INT16 = 0 +DT_INT32 = 1 + + +class _IdentityGS(torch.nn.Module): + """Stand-in for codec.g_s: hand the latent straight back, no UNet/VAE.""" + + def forward(self, y_hat): + return y_hat, None + + +# --------------------------------------------------------------------------- tables + +def write_table_file(codec, path): + """Serialize both CDF groups + the index-quantizer constants. + + Layout (all little-endian, no padding, no alignment guarantees beyond 4 bytes): + + char[8] magic = "AEICCDF\x01" + u32 version = 1 + u32 precision = 16 + u32 bypass_precision = 2 + u32 stream_parts = 2 + u32 num_groups = 2 + -- then num_groups group blocks, in order (group 0 = z, group 1 = y): + u32 num_cdfs R + u32 cdf_width W + i32[R] cdf_length + i32[R] offset + i32[R*W] quantized_cdf, row-major + -- then one index-quantizer block: + char[4] "IDXP" + f64 log_scale_min + f64 log_scale_step + u32 scales_levels = 64 + f32 scale_bound_lo = 0.08 (scales below this -> index -1) + f32 scale_floor = 1e-5 (torch.maximum floor) + f32[64] scale_table + -- trailer: + char[4] "END\x00" + """ + z_cdf = np.asarray(codec.z_quantized_cdf, dtype=np.int32) + z_len = np.asarray(codec.z_cdf_length, dtype=np.int32).reshape(-1) + z_off = np.asarray(codec.z_offset, dtype=np.int32).reshape(-1) + y_cdf = np.asarray(codec.y_quantized_cdf, dtype=np.int32) + y_len = np.asarray(codec.y_cdf_length, dtype=np.int32).reshape(-1) + y_off = np.asarray(codec.y_offset, dtype=np.int32).reshape(-1) + + groups = [("z", z_cdf, z_len, z_off), ("y", y_cdf, y_len, y_off)] + + buf = bytearray() + buf += TABLE_MAGIC + buf += struct.pack("<5I", FORMAT_VERSION, PRECISION, BYPASS_PRECISION, + STREAM_PARTS, len(groups)) + meta = [] + for name, cdf, ln, off in groups: + assert cdf.ndim == 2, (name, cdf.shape) + r, w = cdf.shape + assert ln.shape == (r,) and off.shape == (r,), (name, ln.shape, off.shape) + assert int(ln.max()) <= w, (name, int(ln.max()), w) + assert cdf.min() >= 0 and cdf.max() <= (1 << PRECISION), (name, cdf.min(), cdf.max()) + buf += struct.pack("<2I", r, w) + buf += ln.astype("> 4) + 1 + hdr = 2 if (flag & 0x0F) == 1 else 4 + off = 1 + sizes, total = [], 0 + for _ in range(n_streams - 1): + if hdr == 2: + sz = int.from_bytes(stream[off:off + 2], "little") + else: + sz = int.from_bytes(stream[off:off + 4], "little") + off += hdr + sizes.append(sz) + total += sz + sizes.append(len(stream) - off - total) + parts, p = [], off + for sz in sizes: + parts.append(stream[p:p + sz]) + p += sz + assert p == len(stream), (p, len(stream)) + return flag, hdr, sizes, parts + + +@torch.no_grad() +def capture(codec, x): + """Verbatim codec_practical.AEIC_Codec.compress(), capturing every rANS input.""" + B, C, H, W = x.shape + y_h, y_w = H // 32, W // 32 + z_h, z_w = math.ceil(y_h / 4), math.ceil(y_w / 4) + pad_h, pad_w = z_h * 4 - y_h, z_w * 4 - y_w + + masks = codec.get_mask_four_parts(1, codec.y_channel, y_h, y_w, device=x.device) + z_indexes, z_offset = codec.entropy_bottleneck.get_compress_info( + [1, codec.y_channel // 2, z_h, z_w]) + + y = codec.g_a(x) + y_padded = F.pad(y, (0, pad_w, 0, pad_h), mode="reflect") + z = codec.h_a(y_padded) + z_q = torch.round(z - z_offset) + z_hat = (z_q + z_offset).contiguous() + z_q_np = z_q.flatten().cpu().numpy() + + base = codec.h_s(z_hat)[:, :, :y_h, :y_w] + y_q_list, scales_list, last = [], [], None + for i in range(4): + mask = masks[i] + out = codec.adapter_out[i](codec.g_c(codec.adapter_in[i](base))) + means_supp, scales_supp = out.chunk(2, 1) + means = means_supp * mask + scales = scales_supp * mask + y_q = torch.round(y * mask - means) + y_q_list.append(y_q) + scales_list.append(scales) + last = y_q + means + if i < 3: + base = base * (1 - mask) + last + y_hat_enc = base * (1 - masks[3]) + last + + packed = codec.compress_group(y_q_list, scales_list) + yq = list(packed[:4]) + yidx = list(packed[4:]) + + codec.entropy_coder.reset() + codec.entropy_coder.encode_with_z4y_indexes( + z_q_np, z_indexes, codec.z_cdf_group_index, + yq[0], yidx[0], yq[1], yidx[1], yq[2], yidx[2], yq[3], yidx[3], + codec.y_cdf_group_index) + codec.entropy_coder.flush() + stream = codec.entropy_coder.get_encoded_stream() + + # exactly the int16 the pybind layer forcecasts to + z_q_i16 = z_q_np.astype(np.int16) + z_idx_i16 = z_indexes.reshape(-1).cpu().numpy().astype(np.int16) + yq_i16 = [a.reshape(-1).astype(np.int16) for a in yq] + yidx_i16 = [a.reshape(-1).astype(np.int16) for a in yidx] + + # the forcecast must be lossless: assert the float/int32 originals round-trip + assert np.array_equal(z_q_i16.astype(np.float64), z_q_np.astype(np.float64)) + for a, b in zip(yq_i16, yq): + assert np.array_equal(a.astype(np.float64), b.reshape(-1).astype(np.float64)) + for a, b in zip(yidx_i16, yidx): + assert np.array_equal(a.astype(np.int64), b.reshape(-1).astype(np.int64)) + + return { + "stream": stream, + "y_hat_enc": y_hat_enc, + "z_q": z_q_i16, "z_indexes": z_idx_i16, + "yq": yq_i16, "yidx": yidx_i16, + "shapes": {"y": list(y.shape), "z": list(z.shape), + "z_q_n": int(z_q_i16.size), "yq_n": int(yq_i16[0].size)}, + } + + +def make_synthetic(codec): + """Cases the real corpus never reaches: escape / bypass coding and skipped indexes. + + On ft32 the live symbols are tiny (y_q in [-2,2], z_q in [-3,2]) and sit far from + both ends of their CDF row, so `value == max_value` never fires and the whole + bypass branch of the coder is dead. A Dart port that got it wrong would still + pass every image vector. These hand-built cases exercise it. + + All arrays have EVEN length: py_rans splits each array in half across the two + encoders and its last-part memcpy over-runs the index vector when the length is + odd. Never feed it an odd-length array. + """ + zg, yg = int(codec.z_cdf_group_index), int(codec.y_cdf_group_index) + z_len = np.asarray(codec.z_cdf_length).reshape(-1).astype(np.int64) + z_off = np.asarray(codec.z_offset).reshape(-1).astype(np.int64) + y_len = np.asarray(codec.y_cdf_length).reshape(-1).astype(np.int64) + y_off = np.asarray(codec.y_offset).reshape(-1).astype(np.int64) + + def i16(a): + return np.asarray(a, dtype=np.int16) + + cases = [] + + # --- group z: value == max_value exactly (escape with raw_val == 0, n_bypass == 0) + idx = np.arange(0, 128, dtype=np.int64) + sym = z_off[idx] + (z_len[idx] - 2) # value == max_value + cases.append(("z_escape_exact", zg, i16(sym), i16(idx))) + + # --- group z: value < 0 (odd raw_val) and value > max_value (even raw_val), mixed + sym = np.where(idx % 2 == 0, z_off[idx] - 1 - (idx // 2), z_off[idx] + z_len[idx] + idx) + cases.append(("z_escape_mixed", zg, i16(sym), i16(idx))) + + # --- group z: every in-range symbol of every row, tiled (normal path, all rows) + reps = 16 + idx2 = np.repeat(idx, reps) + k = np.tile(np.arange(reps, dtype=np.int64), 128) % np.repeat(z_len[idx] - 2, reps) + cases.append(("z_dense_normal", zg, i16(z_off[idx2] + k), i16(idx2))) + + # --- group y: huge magnitudes -> long bypass runs (n_bypass >= 3 exercises the + # max_bypass_val continuation loop) + idx = np.arange(0, 64, dtype=np.int64) + big = np.array([32000, -32000, 20000, -20000, 4096, -4096, 3132, -3132], dtype=np.int64) + idx3 = np.repeat(idx, len(big)) + sym = np.tile(big, 64) + cases.append(("y_bypass_long", yg, i16(sym), i16(idx3))) + + # --- group y: index -1 must be skipped by the encoder and decode to 0 + idx4 = np.where(np.arange(128) % 3 == 0, -1, np.arange(128) % 64).astype(np.int64) + sym4 = np.where(idx4 < 0, 12345, y_off[np.maximum(idx4, 0)] + 1).astype(np.int64) + cases.append(("y_skip_indexes", yg, i16(sym4), i16(idx4))) + + # --- group y: near both edges of each row, without escaping + idx5 = np.repeat(idx, 4) + edge = np.tile(np.array([0, 1, -3, -2], dtype=np.int64), 64) + ml = np.repeat(y_len[idx] - 2, 4) + val = np.where(edge >= 0, edge, ml + edge) + cases.append(("y_edges_normal", yg, i16(y_off[idx5] + val), i16(idx5))) + + # --- smallest possible payload: two symbols + cases.append(("y_tiny", yg, i16([y_off[0], y_off[0] + 1]), i16([0, 0]))) + + for name, g, s, i in cases: + assert s.size == i.size and s.size % 2 == 0, (name, s.size, i.size) + return cases + + +def most_likely_symbol(cdf_row, cdf_len, offset): + """(symbol, cost-free-ish) -- the highest-frequency value of a CDF row. + + Used as filler: see pad_case(). + """ + n = int(cdf_len) - 2 + freqs = [int(cdf_row[j + 1]) - int(cdf_row[j]) for j in range(n)] + j = int(np.argmax(freqs)) + return int(offset) + j + + +def encode_case(codec, group, sym, idx): + codec.entropy_coder.reset() + codec.entropy_coder.encoder.encode_with_indexes(sym, idx, group) + codec.entropy_coder.flush() + return codec.entropy_coder.get_encoded_stream() + + +def pad_case(codec, group, sym, idx, cdfs, lens, offs): + """Pad a synthetic case until it is safe for the SHIPPED C++ encoder. + + RansEncoderLib::flush() allocates exactly `_syms.size()` output bytes and writes + backwards from the end, so any sub-stream longer than one byte per pushed symbol + ENTRY runs off the front of the allocation. That is a silent heap under-run: the + bytes are still readable on this platform so the call appears to succeed, but it + scribbles on the allocator and poisons every later encode in the process. + Escape/bypass symbols cost up to 16 bits each and blow that budget easily. + + So the search must never hand an unsafe case to the C++ encoder. Sizing is done + entirely with the pure-Python reference port below (which selfcheck() proves is + byte-identical to the C++ coder on the real image vectors), and the C++ encoder + is called exactly once, on an already-verified-safe case. + """ + row = int(idx[idx >= 0][0]) if (idx >= 0).any() else 0 + fill_sym = most_likely_symbol(cdfs[row], lens[row], offs[row]) + tbl = {group: (cdfs, lens, offs)} + n_fill = 64 + for _ in range(24): + fa = np.full(n_fill, fill_sym, dtype=np.int16) + ia = np.full(n_fill, row, dtype=np.int16) + s = np.concatenate([fa, sym, fa]).astype(np.int16) + i = np.concatenate([ia, idx, ia]).astype(np.int16) + assert s.size % 2 == 0 + each = s.size // 2 + safe = True + for p in range(STREAM_PARTS): + lo = p * each + hi = s.size if p == STREAM_PARTS - 1 else lo + each + ent = [] + ref_push_symbols(ent, s[lo:hi], i[lo:hi], cdfs, lens, offs) + if len(ref_flush(ent)) * 2 > len(ent): # demand 2x headroom + safe = False + break + if safe: + return s, i, n_fill + n_fill *= 2 + raise RuntimeError("could not pad case into the C++ encoder's byte budget") + + +def run_synthetic(codec, cases): + tables = { + int(codec.z_cdf_group_index): (np.asarray(codec.z_quantized_cdf), + np.asarray(codec.z_cdf_length).reshape(-1), + np.asarray(codec.z_offset).reshape(-1)), + int(codec.y_cdf_group_index): (np.asarray(codec.y_quantized_cdf), + np.asarray(codec.y_cdf_length).reshape(-1), + np.asarray(codec.y_offset).reshape(-1)), + } + rows = [] + for name, group, sym0, idx0 in cases: + cdfs, lens, offs = tables[group] + sym, idx, n_fill = pad_case(codec, group, sym0, idx0, cdfs, lens, offs) + stream = encode_case(codec, group, sym, idx) + + bin_path = VEC / f"synth_{name}.bin" + bin_path.write_bytes(stream) + st = os.stat(bin_path).st_size + assert st == len(stream), f"{name}: stat {st} != len {len(stream)}" + + flag, hdr, sizes, parts = parse_container(stream) + gv_path = VEC / f"synth_{name}.gv" + gv_bytes = write_gv_file(gv_path, [("symbols", sym), ("indexes", idx)]) + + # decode back through the C++ decoder; index<0 positions decode to 0 + codec.entropy_coder.decoder.set_stream(np.frombuffer(stream, dtype=np.uint8)) + dec = np.asarray(codec.entropy_coder.decoder.decode_stream(idx, group)) + expect = np.where(idx < 0, 0, sym).astype(np.int16) + ok = bool(np.array_equal(dec.astype(np.int16), expect)) + assert ok, f"{name}: decoder disagreed" + + rows.append({ + "name": name, "cdf_group": int(group), "n": int(sym.size), + "n_filler_each_end": int(n_fill), + "bitstream_file": bin_path.name, "bitstream_bytes_stat": st, + "bitstream_sha256": hashlib.sha256(stream).hexdigest(), + "container_flag": flag, "container_header_bytes": hdr, + "substream_sizes": sizes, + "vector_file": gv_path.name, "vector_bytes_stat": gv_bytes, + "expected_decode_sha256": hashlib.sha256( + expect.astype("6} B parts={sizes} " + f"sym[{sym.min()},{sym.max()}] skip={int((idx<0).sum())} rt={ok}", flush=True) + return rows + + +# ------------------------------------------------- independent reference port +# A from-scratch re-implementation of the C++ coder, written the way the Dart port +# should be written (plain ints, growable buffer, no numpy tricks). If this +# reproduces every golden bitstream byte-for-byte then results/rans_port_spec.md is +# correct and a Dart implementer never has to open the C++. + +RANS_L = 1 << 23 +MAX_BYPASS_VAL = (1 << BYPASS_PRECISION) - 1 + + +def ref_push_symbols(out, symbols, indexes, cdfs, cdf_lengths, offsets): + """Append (start, range) entries for one encode_with_indexes() call.""" + for i in range(len(symbols)): + cdf_idx = int(indexes[i]) + if cdf_idx < 0: + continue + max_value = int(cdf_lengths[cdf_idx]) - 2 + value = int(symbols[i]) - int(offsets[cdf_idx]) + raw_val = 0 + if value < 0: + raw_val = -2 * value - 1 + value = max_value + elif value >= max_value: + raw_val = 2 * (value - max_value) + value = max_value + row = cdfs[cdf_idx] + out.append((int(row[value]), int(row[value + 1]) - int(row[value]))) + if value == max_value: + n_bypass = 0 + while (raw_val >> (n_bypass * BYPASS_PRECISION)) != 0: + n_bypass += 1 + val = n_bypass + while val >= MAX_BYPASS_VAL: + out.append((MAX_BYPASS_VAL, 0)) + val -= MAX_BYPASS_VAL + out.append((val, 0)) + for j in range(n_bypass): + out.append(((raw_val >> (j * BYPASS_PRECISION)) & MAX_BYPASS_VAL, 0)) + + +def ref_flush(entries): + """rANS encode the entry list in reverse; returns the sub-stream bytes.""" + rev = bytearray() # bytes in emission order == reverse of stream order + x = RANS_L + for start, rng in reversed(entries): + if rng != 0: + x_max = rng << 15 + while x >= x_max: + rev.append(x & 0xFF) + x >>= 8 + x = ((x // rng) << PRECISION) + (x % rng) + start + else: + x_max = (1 << (PRECISION - BYPASS_PRECISION)) << 15 + while x >= x_max: + rev.append(x & 0xFF) + x >>= 8 + x = (x << BYPASS_PRECISION) | start + rev.append((x >> 24) & 0xFF) + rev.append((x >> 16) & 0xFF) + rev.append((x >> 8) & 0xFF) + rev.append(x & 0xFF) + rev.reverse() + return bytes(rev) + + +def ref_container(parts): + maximum = max((len(p) for p in parts[:-1]), default=0) + hdr = 4 if maximum > 65535 else 2 + flag = ((len(parts) - 1) << 4) | (1 if hdr == 2 else 0) + out = bytearray([flag]) + for p in parts[:-1]: + out += len(p).to_bytes(hdr, "little") + for p in parts: + out += p + return bytes(out) + + +def ref_encode(calls, tables, n_parts=STREAM_PARTS): + """calls: list of (symbols, indexes, cdf_group). Returns the shipped bytes.""" + entries = [[] for _ in range(n_parts)] + for symbols, indexes, group in calls: + cdfs, lens, offs = tables[group] + total = len(symbols) + each = total // n_parts + for p in range(n_parts): + lo = p * each + hi = total if p == n_parts - 1 else lo + each + ref_push_symbols(entries[p], symbols[lo:hi], indexes[lo:hi], cdfs, lens, offs) + return ref_container([ref_flush(e) for e in entries]) + + +def ref_decode(stream, index_calls, tables, n_parts=STREAM_PARTS): + """index_calls: list of (indexes, cdf_group). Returns list of symbol lists.""" + _, _, _, parts = parse_container(stream) + states, ptrs = [], [] + for p in parts: + states.append(int.from_bytes(p[0:4], "little")) + ptrs.append(4) + results = [] + for indexes, group in index_calls: + cdfs, lens, offs = tables[group] + total = len(indexes) + each = total // n_parts + out = [0] * total + for pi in range(n_parts): + lo = pi * each + hi = total if pi == n_parts - 1 else lo + each + buf = parts[pi] + x = states[pi] + ptr = ptrs[pi] + for i in range(lo, hi): + cdf_idx = int(indexes[i]) + if cdf_idx < 0: + out[i] = 0 + continue + row = cdfs[cdf_idx] + n = int(lens[cdf_idx]) + max_value = n - 2 + cum = x & ((1 << PRECISION) - 1) + s = -1 # upper_bound(row[0:n], cum) - 1 + loo, hii = 0, n + while loo < hii: + mid = (loo + hii) // 2 + if int(row[mid]) > cum: + hii = mid + else: + loo = mid + 1 + s = loo - 1 + start = int(row[s]) + rng = int(row[s + 1]) - start + x = rng * (x >> PRECISION) + (x & ((1 << PRECISION) - 1)) - start + if x < RANS_L: + while x < RANS_L: + x = ((x << 8) | buf[ptr]) & 0xFFFFFFFF + ptr += 1 + value = s + if value == max_value: + def get_bits(nb, _x=None): + nonlocal x, ptr + v = x & ((1 << nb) - 1) + x >>= nb + if x < RANS_L: + x = ((x << 8) | buf[ptr]) & 0xFFFFFFFF + ptr += 1 + return v + val = get_bits(BYPASS_PRECISION) + n_bypass = val + while val == MAX_BYPASS_VAL: + val = get_bits(BYPASS_PRECISION) + n_bypass += val + raw_val = 0 + for j in range(n_bypass): + raw_val |= get_bits(BYPASS_PRECISION) << (j * BYPASS_PRECISION) + value = raw_val >> 1 + if raw_val & 1: + value = -value - 1 + else: + value += max_value + out[i] = value + int(offs[cdf_idx]) + states[pi] = x + ptrs[pi] = ptr + results.append(out) + return results + + +def selfcheck(codec, manifest): + """Re-encode and re-decode every golden vector with the reference port.""" + tables = { + int(codec.z_cdf_group_index): (np.asarray(codec.z_quantized_cdf), + np.asarray(codec.z_cdf_length).reshape(-1), + np.asarray(codec.z_offset).reshape(-1)), + int(codec.y_cdf_group_index): (np.asarray(codec.y_quantized_cdf), + np.asarray(codec.y_cdf_length).reshape(-1), + np.asarray(codec.y_offset).reshape(-1)), + } + zg, yg = int(codec.z_cdf_group_index), int(codec.y_cdf_group_index) + n_ok = n_tot = 0 + + for rec in manifest["images"]: + arrs = read_gv_file(VEC / rec["vector_file"]) + calls = [(arrs["z_q"], arrs["z_indexes"], zg)] + calls += [(arrs[f"y_q{i}"], arrs[f"y_indexes{i}"], yg) for i in range(4)] + want = (VEC / rec["bitstream_file"]).read_bytes() + got = ref_encode(calls, tables) + enc_ok = got == want + dec = ref_decode(want, [(c[1], c[2]) for c in calls], tables) + dec_ok = all( + list(d) == [0 if int(ix) < 0 else int(sy) for sy, ix in zip(c[0], c[1])] + for d, c in zip(dec, calls)) + n_tot += 1 + n_ok += int(enc_ok and dec_ok) + print(f" ref {rec['stem']:<10} encode={'OK' if enc_ok else 'MISMATCH'} " + f"decode={'OK' if dec_ok else 'MISMATCH'} ({len(got)}/{len(want)} B)") + + for rec in manifest["synthetic"]: + arrs = read_gv_file(VEC / rec["vector_file"]) + g = rec["cdf_group"] + calls = [(arrs["symbols"], arrs["indexes"], g)] + want = (VEC / rec["bitstream_file"]).read_bytes() + got = ref_encode(calls, tables) + enc_ok = got == want + dec = ref_decode(want, [(arrs["indexes"], g)], tables) + expect = [0 if int(ix) < 0 else int(sy) + for sy, ix in zip(arrs["symbols"], arrs["indexes"])] + dec_ok = list(dec[0]) == expect + n_tot += 1 + n_ok += int(enc_ok and dec_ok) + print(f" ref synth_{rec['name']:<16} encode={'OK' if enc_ok else 'MISMATCH'} " + f"decode={'OK' if dec_ok else 'MISMATCH'} ({len(got)}/{len(want)} B)") + + print(f"reference port reproduces {n_ok}/{n_tot} vectors exactly") + return n_ok, n_tot + + +def read_gv_file(path): + raw = path.read_bytes() + assert raw[:8] == GV_MAGIC, path + ver, n = struct.unpack_from("<2I", raw, 8) + assert ver == FORMAT_VERSION + off = 16 + descs = [] + for _ in range(n): + name = raw[off:off + 16].rstrip(b"\x00").decode() + dt, cnt = struct.unpack_from("<2I", raw, off + 16) + descs.append((name, dt, cnt)) + off += 24 + out = {} + for name, dt, cnt in descs: + w = 2 if dt == DT_INT16 else 4 + out[name] = np.frombuffer(raw, dtype="", codec.update(force=True)) + real_g_s = codec._modules.pop("g_s") + codec.g_s = _IdentityGS() + + tbl_path = OUT / "aeic_cdf_ft32.bin" + tbl_bytes, tbl_meta = write_table_file(codec, tbl_path) + tbl_sha = hashlib.sha256(tbl_path.read_bytes()).hexdigest() + print(f"tables -> {tbl_path.name} {tbl_bytes} B sha256={tbl_sha[:16]}") + for m in tbl_meta: + print(f" group {m['group']}: {m['rows']}x{m['width']} " + f"cdf_length {m['cdf_length_min']}..{m['cdf_length_max']} " + f"offset {m['offset_min']}..{m['offset_max']} cdf_max {m['cdf_max']}") + + z_h = math.ceil((args.size // 32) / 4) + rows = [] + for path in images: + x = R.load_image(str(path), size=args.size) + cap = capture(codec, x) + stream = cap["stream"] + stem = path.stem + + bin_path = VEC / f"{stem}.bin" + bin_path.write_bytes(stream) + st = os.stat(bin_path).st_size + assert st == len(stream), f"{stem}: stat {st} != len {len(stream)}" + + flag, hdr, sizes, parts = parse_container(stream) + assert sum(sizes) + 1 + hdr * (len(sizes) - 1) == st + + arrays = [("z_q", cap["z_q"]), ("z_indexes", cap["z_indexes"])] + for i in range(4): + arrays.append((f"y_q{i}", cap["yq"][i])) + for i in range(4): + arrays.append((f"y_indexes{i}", cap["yidx"][i])) + gv_path = VEC / f"{stem}.gv" + gv_bytes = write_gv_file(gv_path, arrays) + + ok = None + if not args.no_verify: + codec.entropy_coder.reset() + codec.entropy_coder.set_stream(stream) + y_hat_dec, _ = codec.decompress( + (1, codec.y_channel // 2, z_h, z_h), args.size // 32, args.size // 32) + ok = bool(torch.equal(cap["y_hat_enc"], y_hat_dec)) + assert ok, f"{stem}: round-trip not bit-exact" + + rec = { + "image": path.name, + "stem": stem, + "bitstream_file": bin_path.name, + "bitstream_bytes_stat": st, + "bitstream_sha256": hashlib.sha256(stream).hexdigest(), + "container_flag": flag, + "container_header_bytes": hdr, + "substream_sizes": sizes, + "substream_sha256": [hashlib.sha256(p).hexdigest() for p in parts], + "vector_file": gv_path.name, + "vector_bytes_stat": gv_bytes, + "n_z_symbols": int(cap["z_q"].size), + "n_y_symbols_each": int(cap["yq"][0].size), + "z_q_min": int(cap["z_q"].min()), "z_q_max": int(cap["z_q"].max()), + "y_q_min": int(min(a.min() for a in cap["yq"])), + "y_q_max": int(max(a.max() for a in cap["yq"])), + "y_index_min": int(min(a.min() for a in cap["yidx"])), + "y_index_max": int(max(a.max() for a in cap["yidx"])), + "n_skipped_y_indexes": int(sum(int((a < 0).sum()) for a in cap["yidx"])), + "roundtrip_bitexact": ok, + } + rows.append(rec) + print(f" {stem:<10} {st:>4} B parts={sizes} gv={gv_bytes} B " + f"z_q[{rec['z_q_min']},{rec['z_q_max']}] " + f"y_q[{rec['y_q_min']},{rec['y_q_max']}] " + f"idx[{rec['y_index_min']},{rec['y_index_max']}] " + f"skip={rec['n_skipped_y_indexes']} rt={ok}", flush=True) + + print("synthetic (escape / bypass / skip coverage):") + synth = run_synthetic(codec, make_synthetic(codec)) + + codec._modules["g_s"] = real_g_s + + manifest = { + "checkpoint": args.ckpt, + "size": args.size, + "precision": PRECISION, + "bypass_precision": BYPASS_PRECISION, + "stream_parts": STREAM_PARTS, + "table_file": tbl_path.name, + "table_bytes": tbl_bytes, + "table_sha256": tbl_sha, + "table_groups": tbl_meta, + "z_cdf_group_index": int(codec.z_cdf_group_index), + "y_cdf_group_index": int(codec.y_cdf_group_index), + "images": rows, + "synthetic": synth, + } + (OUT / "manifest.json").write_text(json.dumps(manifest, indent=1)) + + print("\nreference-port self-check (validates results/rans_port_spec.md):") + ok, tot = selfcheck(codec, manifest) + manifest["reference_port_selfcheck"] = {"ok": ok, "total": tot} + (OUT / "manifest.json").write_text(json.dumps(manifest, indent=1)) + assert ok == tot, "reference port does not match the C++ coder" + + n = len(rows) + b = [r["bitstream_bytes_stat"] for r in rows] + print(f"\n{n} vectors, bitstream {min(b)}-{max(b)} B mean {sum(b)/n:.1f}") + print(f"round-trip bit-exact {sum(1 for r in rows if r['roundtrip_bitexact'])}/{n}") + print(f"wrote {OUT / 'manifest.json'}") + + +if __name__ == "__main__": + main() diff --git a/tools/aeic/record_entropy_io.py b/tools/aeic/record_entropy_io.py new file mode 100644 index 00000000..b328766d --- /dev/null +++ b/tools/aeic/record_entropy_io.py @@ -0,0 +1,558 @@ +"""E2 -- record ONNX entropy tensors so the Dart four-stage loop can be tested +end to end inside `flutter test`, where the flutter_onnxruntime platform channel +does not exist. + +lib/services/image_codec_entropy.dart defines an abstract `AeicEntropyNetwork` +seam with exactly three entry points: + + runEncodeSide(image) -> z_q, yq0..3, sc0..3 + runHyperSynthesis(z_q) -> base0 + runStage(stage, base) -> means_supp, scales_supp (UNMASKED) + +A test can inject a fake that replays recorded tensors positionally and so +exercise the REAL Dart masking / build_indexes / rANS code against REAL data. +This script produces one self-describing binary per image containing every +tensor that crosses that seam, in call order, plus the exact rANS bitstream. + +Container format: see CONTAINER_DOC at the bottom of this file (and the report). + +Run: + .venv/bin/python exp/record_entropy_io.py + .venv/bin/python exp/record_entropy_io.py --verify-only # re-check on disk +""" +import argparse +import hashlib +import json +import math +import os +import struct +import sys +from pathlib import Path + +os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") +os.environ.setdefault("AEIC_DEVICE", "cpu") + +import numpy as np +import torch +import torch.nn as nn + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +sys.path.insert(0, str(HERE)) + +import aeic_runner as R # noqa: E402 +from bitexact_encoder import EntropySide, build_stream # noqa: E402 + +SCRATCH = Path(os.environ.get( + "AEIC_SCRATCH", + "/private/tmp/claude-502/-Users-Zach-Documents-mycode-aic/" + "2e06b1a7-5277-4c57-95e0-74b34e166761/scratchpad")) + +OUT_DIR = ROOT / "results" / "golden" / "e2e" +ENC_GRAPH = ROOT / "onnx" / "aeic_entropy_side_fp32_op17.onnx" +# The shipped decode-side graph (task E1). Used when present; otherwise the +# recorder exports its own equivalent scratch graph so it can still run. +SHIPPED_DECODE = ROOT / "onnx" / "aeic_entropy_decode_fp32_op17.onnx" + +MAGIC = b"AEICREC1" +VERSION = 1 +HEADER_BYTES = 32 +ALIGN = 8 + +ENC_NAMES = [ + "y", "z", "z_q", "base0", "means_all", "scales_all", "y_hat", + "yq0", "yq1", "yq2", "yq3", "sc0", "sc1", "sc2", "sc3", +] + +DTYPES = { + np.dtype("float32"): "f32", + np.dtype("int32"): "i32", + np.dtype("int16"): "i16", + np.dtype("uint8"): "u8", +} + + +# -------------------------------------------------------------------------- +# container writer / reader +# -------------------------------------------------------------------------- +class Recording: + """Ordered name -> ndarray map with a JSON index, written little-endian.""" + + def __init__(self, meta=None): + self.arrays = {} + self.order = [] + self.meta = dict(meta or {}) + self.calls = [] + + def put(self, name, arr): + arr = np.ascontiguousarray(arr) + if arr.dtype not in DTYPES: + raise TypeError(f"{name}: unsupported dtype {arr.dtype}") + if name in self.arrays: + raise KeyError(f"duplicate entry {name}") + self.arrays[name] = arr + self.order.append(name) + return name + + def write(self, path): + entries, blob, off = [], [], HEADER_BYTES + for name in self.order: + a = self.arrays[name] + pad = (-off) % ALIGN + if pad: + blob.append(b"\0" * pad) + off += pad + raw = a.tobytes(order="C") + entries.append({"name": name, "dtype": DTYPES[a.dtype], + "shape": list(a.shape), "offset": off, + "length": len(raw)}) + blob.append(raw) + off += len(raw) + pad = (-off) % ALIGN + if pad: + blob.append(b"\0" * pad) + off += pad + index = json.dumps({"meta": self.meta, "calls": self.calls, + "entries": entries}, separators=(",", ":")).encode() + head = (MAGIC + struct.pack(" base0, and base -> (means_supp, scales_supp) for all four stages. + + One graph, two inputs, nine outputs; the recorder asks onnxruntime for a + SUBSET of outputs per call, which prunes the graph exactly the way the + shipped stage-branching decode graph does. Unmasked on purpose: the Dart + seam applies the mask itself. + """ + + def __init__(self, codec, y_h, y_w, z_offset): + super().__init__() + self.h_s = codec.h_s + self.g_c = codec.g_c + self.adapter_in = codec.adapter_in + self.adapter_out = codec.adapter_out + self.y_h, self.y_w = y_h, y_w + self.register_buffer("z_offset", z_offset.clone()) + + def forward(self, z_q, base): + z_hat = (z_q + self.z_offset).contiguous() + base0 = self.h_s(z_hat)[:, :, : self.y_h, : self.y_w] + outs = [] + for i in range(4): + out = self.adapter_out[i](self.g_c(self.adapter_in[i](base))) + m, s = out.chunk(2, 1) + outs += [m, s] + return (base0, *outs) + + +DEC_OUT_NAMES = ["base0"] + [n for i in range(4) + for n in (f"means{i}", f"scales{i}")] + + +class OrtDecodeSide: + """Runner for the scratch nine-output recording graph.""" + + def __init__(self, sess, y_shape, z_shape): + self.sess = sess + self.zero_base = np.zeros(y_shape, dtype=np.float32) + self.zero_zq = np.zeros(z_shape, dtype=np.float32) + + def hyper(self, z_q): + return self.sess.run(["base0"], {"z_q": z_q, "base": self.zero_base})[0] + + def stage(self, i, base): + m, s = self.sess.run([f"means{i}", f"scales{i}"], + {"z_q": self.zero_zq, "base": base}) + return m, s + + +class OrtShippedDecodeSide: + """Runner for the SHIPPED decode graph (inputs z_q, base, stage; outputs + base0, means, scales), driven exactly the way OnnxAeicEntropyNetwork drives + it: every call passes all three inputs and fetches all three outputs, + because flutter_onnxruntime's session.run() has no output-subset API.""" + + def __init__(self, sess, y_shape, z_shape): + self.sess = sess + self.zero_base = np.zeros(y_shape, dtype=np.float32) + self.zero_zq = np.zeros(z_shape, dtype=np.float32) + self.names = [o.name for o in sess.get_outputs()] + + def _run(self, z_q, base, stage): + out = self.sess.run(None, { + "z_q": z_q, "base": base, + "stage": np.asarray([stage], dtype=np.int32)}) + return dict(zip(self.names, out)) + + def hyper(self, z_q): + return self._run(z_q, self.zero_base, -1)["base0"] + + def stage(self, i, base): + o = self._run(self.zero_zq, base, i) + return o["means"], o["scales"] + + +# -------------------------------------------------------------------------- +# the arithmetic the Dart loop mirrors (kept in numpy/torch, verbatim) +# -------------------------------------------------------------------------- +def sequeeze(t): + a, b, c, d = np.split(t, 4, axis=1) + return (a + b) + (c + d) + + +def unsequeeze_with_mask(sq, mask): + parts = np.split(mask, 4, axis=1) + return np.concatenate([sq * p for p in parts], axis=1) + + +def build_indexes(codec, scales_np): + s = torch.from_numpy(np.ascontiguousarray(scales_np)) + return codec.my_build_indexes(s).numpy() + + +def decode_loop(codec, dec, masks_np, coder, z_indexes_t, z_shape, + record=None): + """codec.decompress(), driven through `dec` and recording every call.""" + z_sym = coder.decode_stream(z_indexes_t, codec.z_cdf_group_index) + z_q = z_sym.numpy().reshape(z_shape).astype(np.float32) + + base = dec.hyper(z_q) + if record is not None: + record(0, "hyper_synthesis", -1, + {"z_q": z_q, "stage": np.asarray([-1], dtype=np.int32)}, + {"base0": base}) + + y_parts = [] + for i in range(4): + mask = masks_np[i] + m_supp, s_supp = dec.stage(i, base) + if record is not None: + record(1 + i, "stage", i, + {"base": base, "stage": np.asarray([i], dtype=np.int32)}, + {"means": m_supp, "scales": s_supp}) + means, scales = m_supp * mask, s_supp * mask + sq_scales = sequeeze(scales) + idx = build_indexes(codec, sq_scales) + sym = coder.decode_stream(torch.from_numpy(idx).reshape(-1), + codec.y_cdf_group_index) + sym = sym.numpy().astype(np.float32).reshape(sq_scales.shape) + latent = unsequeeze_with_mask(sym + sequeeze(means), mask) + y_parts.append(latent) + if i < 3: + base = base * (1 - mask) + latent + y_hat = base * (1 - masks_np[3]) + y_parts[3] + return y_hat + + +# -------------------------------------------------------------------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--ckpt", default="AEIC_SE_ft32.pkl") + ap.add_argument("--size", type=int, default=512) + ap.add_argument("--images", nargs="*", default=None) + ap.add_argument("--decode-graph", default=None, + help="reuse an existing decode-side graph instead of " + "exporting the scratch recording graph") + ap.add_argument("--scratch", default=str(SCRATCH)) + ap.add_argument("--verify-only", action="store_true") + args = ap.parse_args() + + if args.verify_only: + return verify_only() + + import onnxruntime as ort + + images = args.images or [ + str(ROOT / "data" / "kodak_raw" / "kodim01.png"), + str(ROOT / "data" / "kodak_raw" / "kodim02.png"), + str(ROOT / "data" / "kodak_raw" / "kodim05.png"), + str(ROOT / "data" / "custom" / "image2.webp"), + str(ROOT / "data" / "custom" / "images.jpeg"), + ] + + net, _ = R.load_model(ckpt=args.ckpt) + codec = net.codec + print("codec.update(force=True) ->", codec.update(force=True)) + + y_h = y_w = args.size // 32 + z_h = z_w = math.ceil(y_h / 4) + z_shape = (1, codec.y_channel // 2, z_h, z_w) + y_shape = (1, codec.y_channel, y_h, y_w) + z_indexes, z_offset = codec.entropy_bottleneck.get_compress_info(list(z_shape)) + + masks = codec.get_mask_four_parts(1, codec.y_channel, y_h, y_w, device="cpu") + masks_np = [m.numpy().astype(np.float32) for m in masks] + + # --- sessions ------------------------------------------------------- + so = ort.SessionOptions() + so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + enc_sess = ort.InferenceSession(str(ENC_GRAPH), so, + providers=["CPUExecutionProvider"]) + enc_names = [o.name for o in enc_sess.get_outputs()] + print(f"encode graph {ENC_GRAPH.name} outputs: {enc_names}") + + scratch = Path(args.scratch) + scratch.mkdir(parents=True, exist_ok=True) + if args.decode_graph: + dec_path = Path(args.decode_graph) + elif SHIPPED_DECODE.exists(): + dec_path = SHIPPED_DECODE + else: + dec_path = scratch / "aeic_entropy_decode_record_fp32.onnx" + if not dec_path.exists(): + print(f"exporting decode-side recording graph -> {dec_path}") + mod = DecodeSideAll(codec, y_h, y_w, z_offset).eval() + with torch.no_grad(): + torch.onnx.export( + mod, + (torch.zeros(z_shape), torch.zeros(y_shape)), + str(dec_path), + input_names=["z_q", "base"], output_names=DEC_OUT_NAMES, + opset_version=17, do_constant_folding=True, dynamo=False) + print(f" decode graph {dec_path.name} " + f"{dec_path.stat().st_size / 2**20:.1f} MB") + dec_sess = ort.InferenceSession(str(dec_path), so, + providers=["CPUExecutionProvider"]) + dec_inputs = [i.name for i in dec_sess.get_inputs()] + dec_outputs = [o.name for o in dec_sess.get_outputs()] + print(f" decode graph inputs {dec_inputs} outputs {dec_outputs}") + if "stage" in dec_inputs: + dec = OrtShippedDecodeSide(dec_sess, y_shape, z_shape) + else: + dec = OrtDecodeSide(dec_sess, y_shape, z_shape) + + # PyTorch reference module, only to prove the ONNX encode graph still + # matches torch on these images (cheap, and it catches a stale graph). + ref = EntropySide(codec, y_h, y_w, z_offset).eval() + + golden_dir = ROOT / "results" / "golden" / "vectors" + written = [] + for path in images: + stem = Path(path).stem + x = R.load_image(path, size=args.size) + xnp = x.cpu().numpy() + + enc = dict(zip(enc_names, enc_sess.run(None, {"image": xnp}))) + with torch.no_grad(): + t_out = ref(x) + tref = {n: v.cpu().numpy() for n, v in zip(ENC_NAMES, t_out)} + # Only the INTEGERS have to match torch: ~99% of the float tensors + # differ between runtimes and that is fine (see results/bitexact_encoder.md). + # For the scales, the integer that matters is my_build_indexes(scales). + torch_match = ( + np.array_equal(enc["z_q"], tref["z_q"]) + and all(np.array_equal(enc[f"yq{i}"], tref[f"yq{i}"]) for i in range(4)) + and all(np.array_equal( + build_indexes(codec, sequeeze(enc[f"sc{i}"])), + build_indexes(codec, sequeeze(tref[f"sc{i}"]))) for i in range(4))) + + stream, sym, idx = build_stream( + codec, enc["z_q"].reshape(-1).copy(), z_indexes, + [torch.from_numpy(enc[f"yq{i}"]) for i in range(4)], + [torch.from_numpy(enc[f"sc{i}"]) for i in range(4)]) + + golden = golden_dir / f"{stem}.bin" + golden_match = None + if golden.exists(): + golden_match = (golden.read_bytes() == stream) + + # --- decode replay, recording every seam call ------------------- + rec = Recording(meta={ + "format": "aeic-entropy-e2e-recording", + "version": VERSION, + "image": Path(path).name, + "stem": stem, + "checkpoint": args.ckpt, + "size": args.size, + "y_shape": list(y_shape), + "z_shape": list(z_shape), + "squeezed_shape": [1, codec.y_channel // 4, y_h, y_w], + "encode_graph": ENC_GRAPH.name, + "encode_graph_sha256": _sha(ENC_GRAPH), + "decode_graph": dec_path.name, + "decode_graph_sha256": _sha(dec_path), + "z_cdf_group": int(codec.z_cdf_group_index), + "y_cdf_group": int(codec.y_cdf_group_index), + "cdf_table": "aeic_cdf_ft32.bin", + "byte_order": "little", + }) + for n in ENC_NAMES: + rec.put(f"enc/{n}", enc[n]) + rec.put("enc/bitstream", np.frombuffer(stream, dtype=np.uint8)) + rec.put("enc/z_indexes", z_indexes.reshape(-1).numpy().astype(np.int16)) + rec.put("enc/z_symbols", + np.asarray(enc["z_q"], dtype=np.int16).reshape(-1)) + for i in range(4): + rec.put(f"enc/symbols{i}", np.asarray(sym[i], dtype=np.int16)) + rec.put(f"enc/indexes{i}", np.asarray(idx[i], dtype=np.int16)) + + def record(k, kind, stage, ins, outs): + e = {"index": k, "kind": kind, "stage": stage, + "inputs": {}, "outputs": {}} + for kk, v in ins.items(): + e["inputs"][kk] = rec.put(f"dec/call{k}/in/{kk}", np.asarray(v)) + for kk, v in outs.items(): + e["outputs"][kk] = rec.put(f"dec/call{k}/out/{kk}", + np.asarray(v, dtype=np.float32)) + rec.calls.append(e) + + codec.entropy_coder.set_stream(stream) + y_hat_dec = decode_loop(codec, dec, masks_np, codec.entropy_coder, + z_indexes.reshape(-1), z_shape, record=record) + rec.put("dec/y_hat", y_hat_dec.astype(np.float32)) + rec.meta["bitstream_bytes"] = len(stream) + rec.meta["bitstream_sha256"] = hashlib.sha256(stream).hexdigest() + rec.meta["encode_graph_matches_torch_symbols"] = bool(torch_match) + rec.meta["golden_vector_match"] = golden_match + rec.meta["decoded_y_hat_equals_encoder_y_hat"] = bool( + np.array_equal(y_hat_dec, enc["y_hat"])) + rec.meta["decoded_y_hat_max_abs_diff"] = float( + np.abs(y_hat_dec.astype(np.float64) + - enc["y_hat"].astype(np.float64)).max()) + + dst = OUT_DIR / f"{stem}.aeicrec" + nbytes = rec.write(dst) + written.append(dst) + print(f" {stem:<10} stream={len(stream)}B golden={golden_match} " + f"torch_sym={torch_match} " + f"y_hat_exact={rec.meta['decoded_y_hat_equals_encoder_y_hat']} " + f"(max {rec.meta['decoded_y_hat_max_abs_diff']:.3g}) " + f"-> {dst.name} {nbytes/2**20:.2f} MB", flush=True) + + # index file so a Dart test can enumerate fixtures without a directory scan + manifest = {"format": "aeic-entropy-e2e-recording", "version": VERSION, + "checkpoint": args.ckpt, "size": args.size, + "files": [{"file": p.name, + "bytes": p.stat().st_size, + "sha256": hashlib.sha256(p.read_bytes()).hexdigest()} + for p in written]} + (OUT_DIR / "manifest.json").write_text(json.dumps(manifest, indent=1)) + print(f"wrote {OUT_DIR}/manifest.json") + + print("\n=== verifying recordings from disk ===") + verify_only() + + +def verify_only(): + """Reload every recording and prove it round-trips through the C++ coder.""" + net, _ = R.load_model(ckpt="AEIC_SE_ft32.pkl") + codec = net.codec + codec.update(force=True) + ok_all = True + for path in sorted(OUT_DIR.glob("*.aeicrec")): + index, t = read_recording(path) + meta = index["meta"] + stream = t["enc/bitstream"].tobytes() + + # 1) recorded encode tensors -> bitstream + s2, sym2, idx2 = build_stream( + codec, t["enc/z_q"].reshape(-1).astype(np.float32).copy(), + torch.from_numpy(t["enc/z_indexes"].astype(np.int32)).reshape( + meta["z_shape"]), + [torch.from_numpy(t[f"enc/yq{i}"].copy()) for i in range(4)], + [torch.from_numpy(t[f"enc/sc{i}"].copy()) for i in range(4)]) + enc_ok = (s2 == stream) + sym_ok = all(np.array_equal(np.asarray(sym2[i], dtype=np.int16), + t[f"enc/symbols{i}"]) for i in range(4)) + idx_ok = all(np.array_equal(np.asarray(idx2[i], dtype=np.int16), + t[f"enc/indexes{i}"]) for i in range(4)) + + # 2) recorded decode tensors -> same symbols, same y_hat + masks = codec.get_mask_four_parts(1, codec.y_channel, + meta["y_shape"][2], meta["y_shape"][3], + device="cpu") + masks_np = [m.numpy().astype(np.float32) for m in masks] + calls = index["calls"] + + class Replay: + def hyper(self, z_q): + assert np.array_equal(z_q, t[calls[0]["inputs"]["z_q"]]) + return t[calls[0]["outputs"]["base0"]] + + def stage(self, i, base): + c = calls[1 + i] + assert np.array_equal(base, t[c["inputs"]["base"]]), \ + f"{path.name}: stage {i} base input drifted" + return t[c["outputs"]["means"]], t[c["outputs"]["scales"]] + + codec.entropy_coder.set_stream(stream) + y_hat = decode_loop( + codec, Replay(), masks_np, codec.entropy_coder, + torch.from_numpy(t["enc/z_indexes"].astype(np.int32)), + tuple(meta["z_shape"])) + dec_ok = np.array_equal(y_hat, t["dec/y_hat"]) + + ok = enc_ok and sym_ok and idx_ok and dec_ok + ok_all &= ok + print(f" {path.name:<22} {path.stat().st_size/2**20:5.2f} MB " + f"encode->bitstream={enc_ok} symbols={sym_ok} indexes={idx_ok} " + f"decode_replay_y_hat={dec_ok} " + f"golden={meta.get('golden_vector_match')} {'OK' if ok else 'FAIL'}") + print("ALL RECORDINGS VERIFIED" if ok_all else "VERIFICATION FAILED") + return 0 if ok_all else 1 + + +CONTAINER_DOC = """ +.aeicrec container (little-endian throughout) + + 0 8 magic b"AEICREC1" + 8 4 uint32 version = 1 + 12 4 uint32 flags = 0 + 16 8 uint64 index_offset (byte offset of the JSON index) + 24 4 uint32 index_length (bytes of the JSON index) + 28 4 uint32 reserved = 0 + 32 .. tensor blob, every tensor 8-byte aligned, C order + index_offset .. +index_length UTF-8 JSON index + +JSON index: + {"meta": {...}, "calls": [...], "entries": + [{"name":..., "dtype":"f32"|"i32"|"i16"|"u8", "shape":[...], + "offset":, "length":}, ...]} + +Dart: read the 32-byte header, jsonDecode the index, then for f32 entries use +ByteData/Float32List.view(buffer, offset, length ~/ 4) -- offsets are 8-byte +aligned so the typed-data views are always legal. +""" + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/untranslated.json b/untranslated.json index 14f7f7cc..a851ed41 100644 --- a/untranslated.json +++ b/untranslated.json @@ -12,14 +12,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -30,17 +35,81 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "de": [ "settings_regionFetchRegions", "settings_regionFetchRegionsFail", "settings_regionFetchRegionsAlreadyExists", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -51,7 +120,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "es": [ @@ -67,14 +195,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -85,7 +218,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "fr": [ @@ -101,14 +293,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -119,7 +316,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "hu": [ @@ -135,14 +391,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -153,7 +414,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "it": [ @@ -169,14 +489,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -187,7 +512,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "ja": [ @@ -203,14 +587,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -221,7 +610,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "ko": [ @@ -237,14 +685,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -255,7 +708,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "nl": [ @@ -271,14 +783,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -289,7 +806,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "pl": [ @@ -305,14 +881,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -323,7 +904,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "pt": [ @@ -339,14 +979,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -357,7 +1002,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "ru": [ @@ -373,14 +1077,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -391,7 +1100,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "sk": [ @@ -407,14 +1175,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -425,7 +1198,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "sl": [ @@ -441,14 +1273,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -459,7 +1296,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "sv": [ @@ -475,14 +1371,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -493,7 +1394,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "uk": [ @@ -509,14 +1469,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -527,7 +1492,66 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ], "zh": [ @@ -543,14 +1567,19 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "settings_infoHardware", + "settings_infoFirmware", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", "repeater_pathHashModeOption3", + "appSettings_batteryLipoHv", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", "channels_clearRegion", + "chat_sendImage", + "chat_imagePickFailed", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -561,6 +1590,65 @@ "repeater_pubKey", "repeater_pubKeyHelper", "repeater_pubKeyPrefix", - "repeater_pubKeyPrefixHelper" + "repeater_pubKeyPrefixHelper", + "imageMessages_enableTitle", + "imageMessages_enableSubtitle", + "imageMessages_modelSectionTitle", + "imageMessages_downloadModel", + "imageMessages_cancelDownload", + "imageMessages_removeModel", + "imageMessages_modelReady", + "imageMessages_modelNotPublished", + "imageMessages_downloadFailed", + "imageMessages_autoProcessTitle", + "imageMessages_autoProcessSubtitle", + "imageSend_title", + "imageSend_cropNote", + "imageSend_originalSize", + "imageSend_onAirSize", + "imageSend_quality", + "imageSend_qualityStandard", + "imageSend_qualityHigh", + "imageSend_packetsLabel", + "imageSend_airtimeLabel", + "imageSend_sizeLabel", + "imageSend_packetsCount", + "imageSend_range", + "imageSend_unknownValue", + "imageSend_radioUnknownTitle", + "imageSend_radioUnknownBody", + "imageSend_longSendTitle", + "imageSend_longSendBody", + "imageSend_floodNote", + "imageSend_parityTitle", + "imageSend_paritySubtitle", + "imageSend_send", + "imageSend_cancel", + "imageSend_encodeFailed", + "imageSend_codecDownloading", + "imageSend_codecUnavailable", + "imageSend_codecDisabled", + "imageSend_deviceUnsupported", + "imageSend_directMessagesUnsupported", + "imageSend_tooLarge", + "imageSend_sentConfirmation", + "imageSend_sendFailed", + "imageSend_sendingProgress", + "receivedImage_senderPrefix", + "receivedImage_incoming", + "receivedImage_queued", + "receivedImage_tapToDecode", + "receivedImage_decoding", + "receivedImage_incomplete", + "receivedImage_corrupt", + "receivedImage_decoderMissing", + "receivedImage_evicted", + "receivedImage_retry", + "receivedImage_decodeAgain", + "receivedImage_openSettings", + "receivedImage_tapToProcess", + "receivedImage_awaiting", + "imageSend_secondsValue", + "imageSend_minutesSecondsValue" ] } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index cd4fc19b..d321cc51 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,13 +6,19 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterBluePlusPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); + FlutterOnnxruntimePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterOnnxruntimePlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 533a1712..ae56b7cf 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_blue_plus_winrt + flutter_onnxruntime share_plus url_launcher_windows )