Add ability to send images over lora

This commit is contained in:
Zach
2026-08-10 23:20:46 -07:00
parent 736eb064bf
commit eee72b4c65
129 changed files with 27099 additions and 58 deletions
+234 -12
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
@@ -9,7 +11,13 @@ import 'screens/chrome_required_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
import 'models/image_codec_support.dart';
import 'screens/scanner_screen.dart';
import 'services/image_chunk_transport.dart';
import 'services/image_codec_service.dart';
import 'services/image_codec_settings_store.dart';
import 'services/received_image_blob_store_factory.dart';
import 'services/received_image_store.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
@@ -26,6 +34,7 @@ import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart';
import 'theme/mesh_theme.dart';
import 'utils/app_logger.dart';
import 'widgets/image_send_codec_binding.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -59,9 +68,52 @@ void main() async {
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings
// Load settings before anything reads them. The image stack below takes its
// model registry and its "process automatically" default straight off
// `appSettingsService.settings`, so this cannot stay where it used to be
// (after the constructions) without those two starting out wrong.
await appSettingsService.loadSettings();
// ---- image messages (AEIC over GRP_DATA) --------------------------------
// The codec owns the ONNX decoder; the store owns received-image state and
// the decode queue; the reassembler/transport pair is the receive path the
// connector feeds raw frames into.
final imageCodecService = ImageCodecService(
appSettingsService,
settingsStore: AppSettingsImageCodecStore(appSettingsService),
);
final receivedImageStore = ReceivedImageStore(
// Without a file-backed store this silently falls back to memory and every
// received image — sidecar included — is gone at the next launch.
blobs: createReceivedImageBlobStore(),
decoder: ImageCodecServiceDecoder(imageCodecService),
processAutomatically: appSettingsService.settings.imageProcessAutomatically,
);
// A decode peaks around 2.16 GiB, so the setting is the user's consent to
// spend it. Mirrored on every settings change; the store applies it to
// future arrivals only, so turning it on does not decode a backlog.
appSettingsService.addListener(() {
receivedImageStore.processAutomatically =
appSettingsService.settings.imageProcessAutomatically;
});
// Availability/isBusy changes are the only thing that un-parks a decode
// queue that stopped because the codec was unusable or busy.
imageCodecService.addListener(receivedImageStore.notifyDecoderChanged);
final imageReassembler = ImageStreamReassembler(store: receivedImageStore);
final imageTransport = ImageChunkTransport(
reassembler: imageReassembler,
// The UI sends whole chunk sets through connector.sendImageChunks so it
// gets real inter-chunk pacing and per-chunk progress; this closure only
// keeps ImageChunkTransport.sendImage() usable on its own.
send: (blob, channelIndex) => connector.sendImageChunks(
<Uint8List>[blob],
channelIndex: channelIndex,
interChunkDelay: Duration.zero,
),
// Overwritten by onImageSenderPrefix as soon as SELF_INFO lands.
senderPrefix: 0,
);
// Initialize app logger
appLogger.initialize(
appDebugLogService,
@@ -79,6 +131,8 @@ void main() async {
await chatTextScaleService.initialize();
await translationService.refreshDownloadedModels();
await imageCodecService.refreshDownloadedModels();
await receivedImageStore.load();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
@@ -92,6 +146,12 @@ void main() async {
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
imageCodecService: imageCodecService,
imageTransport: imageTransport,
// ImageStreamReassembler.addChunk already forwards every outcome to the
// store together with the channel index (which ImageChunkOutcome itself
// does not carry), so onImageChunk would be a second, channel-blind path.
onImageSenderPrefix: (prefix) => imageReassembler.selfPrefix = prefix,
);
await connector.loadContactCache();
@@ -116,10 +176,107 @@ void main() async {
translationService: translationService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
imageCodecService: imageCodecService,
receivedImageStore: receivedImageStore,
imageReassembler: imageReassembler,
),
);
}
/// [ReceivedImageDecoder] over [ImageCodecService].
///
/// Pure delegation. It exists so `received_image_store.dart` never imports
/// `image_codec_service.dart` (and so the store stays testable without an
/// 872 MB ONNX graph).
class ImageCodecServiceDecoder implements ReceivedImageDecoder {
final ImageCodecService service;
const ImageCodecServiceDecoder(this.service);
@override
ImageCodecAvailability get availability => service.availability;
@override
bool get isBusy => service.isBusy;
@override
Future<ImageCodecResult?> decodeBitstream({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
}) => service.decodeBitstream(
bitstream: bitstream,
ratePoint: ratePoint,
resolution: resolution,
);
@override
void cancelCodecJob() => service.cancelCodecJob();
}
/// [ImageCodecSettingsStore] backed by [AppSettingsService].
///
/// Replaces `PrefsImageCodecSettingsStore` now that the five `imageCodec*`
/// fields live on [AppSettings]; the JSON keys were already the same, so a
/// user upgrading keeps their model registry only if it was written through
/// this adapter — the old standalone `image_codec_settings` blob is not
/// migrated, because a placeholder-URL build cannot have produced one.
class AppSettingsImageCodecStore implements ImageCodecSettingsStore {
final AppSettingsService _service;
const AppSettingsImageCodecStore(this._service);
@override
ImageCodecPreferences get preferences => _service.settings.imageCodec;
@override
Future<void> load() async {
// AppSettingsService.loadSettings() already ran in main().
}
@override
Future<void> save(ImageCodecPreferences preferences) =>
_service.setImageCodecPreferences(preferences);
}
/// An [ImageReassembler] that (a) learns the local sender prefix after
/// SELF_INFO and (b) forwards every outcome to [ReceivedImageStore] together
/// with the channel index.
///
/// Both exist because of upstream shapes this file cannot change:
/// * `ImageReassembler.selfPrefix` is `final`, but the local public key does
/// not exist until `RESP_CODE_SELF_INFO`, so a reassembler built here would
/// start with a null prefix and never be able to drop our own flood echo.
/// Overriding the getter is the smallest fix that does not touch the
/// transport; see the report's "needs from others".
/// * `ImageChunkOutcome` carries no channel index, so the connector's
/// `onImageChunk` callback cannot call `handleOutcome`, which requires one.
/// Intercepting `addChunk` — the only place the index is in scope — can.
class ImageStreamReassembler extends ImageReassembler {
final ReceivedImageStore store;
int? _selfPrefix;
ImageStreamReassembler({required this.store})
: super(onFailed: ((failure) => unawaited(store.handleFailure(failure))));
@override
int? get selfPrefix => _selfPrefix;
set selfPrefix(int? value) => _selfPrefix = value;
@override
ImageChunkOutcome addChunk(
Uint8List blob, {
int channelIndex = 0,
DateTime? now,
}) {
final outcome = super.addChunk(blob, channelIndex: channelIndex, now: now);
unawaited(store.handleOutcome(outcome, channelIndex: channelIndex));
return outcome;
}
}
void _registerThirdPartyLicenses() {
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
@@ -141,7 +298,7 @@ https://creativecommons.org/licenses/by/4.0/
});
}
class MeshCoreApp extends StatelessWidget {
class MeshCoreApp extends StatefulWidget {
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
@@ -154,6 +311,9 @@ class MeshCoreApp extends StatelessWidget {
final TranslationService translationService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
final ImageCodecService imageCodecService;
final ReceivedImageStore receivedImageStore;
final ImageStreamReassembler imageReassembler;
const MeshCoreApp({
super.key,
@@ -169,24 +329,86 @@ class MeshCoreApp extends StatelessWidget {
required this.translationService,
required this.uiViewStateService,
required this.timeoutPredictionService,
required this.imageCodecService,
required this.receivedImageStore,
required this.imageReassembler,
});
@override
State<MeshCoreApp> createState() => _MeshCoreAppState();
}
/// How often abandoned image streams are swept.
///
/// `ImageReassembler.evictExpired` otherwise only runs from `addChunk`, so a
/// sender that stops mid-image would leave its bubble stuck on "2 of 3
/// packets" until some unrelated image arrived.
const Duration _kImageSweepInterval = Duration(seconds: 5);
class _MeshCoreAppState extends State<MeshCoreApp>
with WidgetsBindingObserver {
Timer? _imageSweepTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_imageSweepTimer = Timer.periodic(
_kImageSweepInterval,
(_) => widget.imageReassembler.evictExpired(),
);
}
@override
void dispose() {
_imageSweepTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
widget.receivedImageStore.setForeground(
state == AppLifecycleState.resumed,
);
// ~2.7 GiB resident in a backgrounded app is a low-memory-killer kill, so
// unlike the translation stack the graph is dropped on background rather
// than held until the radio disconnects.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden) {
unawaited(widget.imageCodecService.handleMemoryPressure());
unawaited(widget.receivedImageStore.handleMemoryPressure());
}
}
@override
void didHaveMemoryPressure() {
super.didHaveMemoryPressure();
unawaited(widget.imageCodecService.handleMemoryPressure());
unawaited(widget.receivedImageStore.handleMemoryPressure());
}
@override
Widget build(BuildContext context) {
final connector = widget.connector;
final storage = widget.storage;
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: translationService),
ChangeNotifierProvider.value(value: uiViewStateService),
ChangeNotifierProvider.value(value: widget.retryService),
ChangeNotifierProvider.value(value: widget.pathHistoryService),
ChangeNotifierProvider.value(value: widget.appSettingsService),
ChangeNotifierProvider.value(value: widget.bleDebugLogService),
ChangeNotifierProvider.value(value: widget.appDebugLogService),
ChangeNotifierProvider.value(value: widget.chatTextScaleService),
ChangeNotifierProvider.value(value: widget.translationService),
ChangeNotifierProvider.value(value: widget.uiViewStateService),
Provider.value(value: storage),
ChangeNotifierProvider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
ChangeNotifierProvider.value(value: widget.mapTileCacheService),
ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
ChangeNotifierProvider.value(value: widget.imageCodecService),
ChangeNotifierProvider.value(value: widget.receivedImageStore),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {