diff --git a/lib/screens/corner_adjust_screen.dart b/lib/screens/corner_adjust_screen.dart index faf167f..48ad7da 100644 --- a/lib/screens/corner_adjust_screen.dart +++ b/lib/screens/corner_adjust_screen.dart @@ -1,9 +1,8 @@ import 'dart:async'; -import 'dart:isolate'; import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show compute; import 'package:flutter/material.dart'; import '../models/scanned_page.dart'; @@ -18,11 +17,20 @@ const _filterChipDecodeSize = 256; abstract interface class CornerAdjustOperations { Future decodeSize(Uint8List imageBytes); + Future?> detectCorners(Uint8List imageBytes); + Future> buildPreviews( Uint8List imageBytes, List corners, ); + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }); + Future processForExport( Uint8List imageBytes, List corners, { @@ -33,38 +41,113 @@ abstract interface class CornerAdjustOperations { }); } +typedef _PreviewWorkerRequest = ({ + Uint8List imageBytes, + List cornerCoordinates, +}); +typedef _FinalPreviewWorkerRequest = ({ + Uint8List imageBytes, + int rotationQuarterTurns, + double brightness, + double contrast, +}); +typedef _ExportWorkerRequest = ({ + Uint8List imageBytes, + List cornerCoordinates, + PageFilter filter, + int rotationQuarterTurns, + double brightness, + double contrast, +}); + +List _serializeCorners(List corners) => [ + for (final corner in corners) ...[corner.dx, corner.dy], +]; + +List _deserializeCorners(List coordinates) => [ + for (var i = 0; i < coordinates.length; i += 2) + Offset(coordinates[i], coordinates[i + 1]), +]; + +List? _detectCornersWorker(Uint8List imageBytes) { + final corners = detectCorners(imageBytes); + return corners == null ? null : _serializeCorners(corners); +} + +Map _buildPreviewsWorker(_PreviewWorkerRequest request) { + final warped = warpDocument( + request.imageBytes, + _deserializeCorners(request.cornerCoordinates), + maxPixels: maxPreviewWarpPixels, + maxEdge: maxPreviewWarpEdge, + ); + return { + for (final filter in PageFilter.values) filter: applyFilter(warped, filter), + }; +} + +Uint8List _buildFinalPreviewWorker(_FinalPreviewWorkerRequest request) { + final rotated = rotateImage(request.imageBytes, request.rotationQuarterTurns); + return adjustBrightnessContrast( + rotated, + brightness: request.brightness, + contrast: request.contrast, + ); +} + +Uint8List _processForExportWorker(_ExportWorkerRequest request) => + processDocument( + request.imageBytes, + _deserializeCorners(request.cornerCoordinates), + filter: request.filter, + rotationQuarterTurns: request.rotationQuarterTurns, + brightness: request.brightness, + contrast: request.contrast, + ); + class DefaultCornerAdjustOperations implements CornerAdjustOperations { const DefaultCornerAdjustOperations(); @override Future decodeSize(Uint8List imageBytes) async { final codec = await ui.instantiateImageCodec(imageBytes); - final frame = await codec.getNextFrame(); - final size = Size( - frame.image.width.toDouble(), - frame.image.height.toDouble(), - ); - frame.image.dispose(); - codec.dispose(); - return size; + ui.FrameInfo? frame; + try { + frame = await codec.getNextFrame(); + return Size(frame.image.width.toDouble(), frame.image.height.toDouble()); + } finally { + frame?.image.dispose(); + codec.dispose(); + } + } + + @override + Future?> detectCorners(Uint8List imageBytes) async { + final coordinates = await compute(_detectCornersWorker, imageBytes); + return coordinates == null ? null : _deserializeCorners(coordinates); } @override Future> buildPreviews( Uint8List imageBytes, List corners, - ) async { - final warped = warpDocument( - imageBytes, - corners, - maxPixels: maxPreviewWarpPixels, - maxEdge: maxPreviewWarpEdge, - ); - return { - for (final filter in PageFilter.values) - filter: applyFilter(warped, filter), - }; - } + ) => compute(_buildPreviewsWorker, ( + imageBytes: imageBytes, + cornerCoordinates: _serializeCorners(corners), + )); + + @override + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) => compute(_buildFinalPreviewWorker, ( + imageBytes: imageBytes, + rotationQuarterTurns: rotationQuarterTurns, + brightness: brightness, + contrast: contrast, + )); @override Future processForExport( @@ -74,16 +157,41 @@ class DefaultCornerAdjustOperations implements CornerAdjustOperations { required int rotationQuarterTurns, required double brightness, required double contrast, - }) { - Uint8List process() => processDocument( - imageBytes, - corners, - filter: filter, - rotationQuarterTurns: rotationQuarterTurns, - brightness: brightness, - contrast: contrast, - ); - return kIsWeb ? Future.value(process()) : Isolate.run(process); + }) => compute(_processForExportWorker, ( + imageBytes: imageBytes, + cornerCoordinates: _serializeCorners(corners), + filter: filter, + rotationQuarterTurns: rotationQuarterTurns, + brightness: brightness, + contrast: contrast, + )); +} + +class _QueuedWorkResult { + const _QueuedWorkResult.started(this.value) : started = true; + const _QueuedWorkResult.skipped() : started = false, value = null; + + final bool started; + final T? value; +} + +/// Serializes OpenCV workers owned by one scanner session. +/// +/// A route may be disposed before its worker finishes, so every editor opened +/// by the same home screen must share this queue. +class ImageProcessingQueue { + Future _tail = Future.value(); + + Future run(Future Function() work) { + final result = Completer(); + _tail = _tail.then((_) async { + try { + result.complete(await work()); + } catch (error, stackTrace) { + result.completeError(error, stackTrace); + } + }); + return result.future; } } @@ -116,6 +224,7 @@ class CornerAdjustScreen extends StatefulWidget { this.initialBrightness = 0.0, this.initialContrast = 1.0, this.operations = const DefaultCornerAdjustOperations(), + this.processingQueue, }); final Uint8List originalBytes; @@ -125,6 +234,7 @@ class CornerAdjustScreen extends StatefulWidget { final double initialBrightness; final double initialContrast; final CornerAdjustOperations operations; + final ImageProcessingQueue? processingQueue; @override State createState() => _CornerAdjustScreenState(); @@ -135,6 +245,9 @@ class _CornerAdjustScreenState extends State { List? _corners; bool _isProcessing = false; String? _error; + late final ImageProcessingQueue _processingQueue; + int _initializationGeneration = 0; + int _exportGeneration = 0; _Step _step = _Step.corners; LocalHistoryEntry? _filterHistoryEntry; @@ -147,24 +260,48 @@ class _CornerAdjustScreenState extends State { // bounded export resolution. Map? _filterPreviews; bool _isGeneratingPreviews = false; + int _previewGeneration = 0; // Rotation + brightness/contrast applied on top of _filterPreviews[ // _selectedFilter], recomputed on rotate/slider-release/filter-change // rather than baked into _filterPreviews (which only need to answer // "what does each filter choice look like", not track these extras). Uint8List? _finalPreviewBytes; + bool _isGeneratingFinalPreview = false; + int _finalPreviewGeneration = 0; @override void initState() { super.initState(); + _processingQueue = widget.processingQueue ?? ImageProcessingQueue(); _initialize(); } + Future<_QueuedWorkResult> _enqueueWork({ + required bool Function() canStart, + required Future Function() work, + }) { + return _processingQueue.run(() async { + if (!canStart()) return _QueuedWorkResult.skipped(); + return _QueuedWorkResult.started(await work()); + }); + } + Future _initialize() async { + final generation = ++_initializationGeneration; try { final size = await widget.operations.decodeSize(widget.originalBytes); - final candidateCorners = - widget.initialCorners ?? detectCorners(widget.originalBytes); + List? candidateCorners = widget.initialCorners; + if (candidateCorners == null) { + await WidgetsBinding.instance.endOfFrame; + if (!mounted || generation != _initializationGeneration) return; + final detection = await _enqueueWork?>( + canStart: () => mounted && generation == _initializationGeneration, + work: () => widget.operations.detectCorners(widget.originalBytes), + ); + if (!detection.started) return; + candidateCorners = detection.value; + } final corners = _hasRenderableCorners(candidateCorners) ? candidateCorners! : _fullBoundsCorners(size); @@ -183,6 +320,15 @@ class _CornerAdjustScreenState extends State { } } + @override + void dispose() { + _initializationGeneration++; + _previewGeneration++; + _finalPreviewGeneration++; + _exportGeneration++; + super.dispose(); + } + bool _hasRenderableCorners(List? corners) => corners != null && corners.length == 4 && @@ -204,13 +350,23 @@ class _CornerAdjustScreenState extends State { void _showError(String message) => showTransientMessage(context, message); Future _updatePreviews() async { - final corners = _corners; - if (corners == null) return; + final currentCorners = _corners; + if (currentCorners == null || !mounted) return; + final corners = List.of(currentCorners); + final generation = ++_previewGeneration; + _finalPreviewGeneration++; setState(() { _isGeneratingPreviews = true; + _isGeneratingFinalPreview = false; _filterPreviews = null; _finalPreviewBytes = null; }); + + // Do not let an immediately completing worker erase the progress state + // before Flutter has painted it once. + await WidgetsBinding.instance.endOfFrame; + if (!mounted || generation != _previewGeneration) return; + // Keep geometry failures separate from decoder/backend failures so the // recovery guidance matches what the user can actually fix. try { @@ -220,7 +376,7 @@ class _CornerAdjustScreenState extends State { maxEdge: maxPreviewWarpEdge, ); } on ArgumentError { - if (!mounted) return; + if (!mounted || generation != _previewGeneration) return; setState(() => _isGeneratingPreviews = false); _showError( 'Could not preview this crop. Adjust the corners and try again.', @@ -229,43 +385,69 @@ class _CornerAdjustScreenState extends State { } try { - final previews = await widget.operations.buildPreviews( - widget.originalBytes, - corners, + final queuedPreviews = await _enqueueWork>( + canStart: () => mounted && generation == _previewGeneration, + work: () => + widget.operations.buildPreviews(widget.originalBytes, corners), ); - if (!mounted) return; + if (!queuedPreviews.started || + !mounted || + generation != _previewGeneration) { + return; + } setState(() { - _filterPreviews = previews; + _filterPreviews = queuedPreviews.value!; _isGeneratingPreviews = false; }); - _updateFinalPreview(); + unawaited(_updateFinalPreview()); } catch (_) { - if (!mounted) return; + if (!mounted || generation != _previewGeneration) return; setState(() => _isGeneratingPreviews = false); _showError('Could not process this photo. Try another image.'); } } /// Applies the current rotation + brightness/contrast on top of the - /// selected filter's cached preview. Cheap enough (a single decode + - /// OpenCV op + encode, no contour search) to redo on every rotate tap - /// or slider release, unlike the full warp+filter set in - /// [_updatePreviews]. - void _updateFinalPreview() { + /// selected filter's cached preview. This still decodes and transforms an + /// image, so the default operations run it outside the UI isolate. + Future _updateFinalPreview() async { final base = _filterPreviews?[_selectedFilter]; - if (base == null) return; + if (base == null || !mounted) return; + final rotationQuarterTurns = _rotationQuarterTurns; + final brightness = _brightness; + final contrast = _contrast; + final generation = ++_finalPreviewGeneration; + setState(() { + _isGeneratingFinalPreview = true; + _finalPreviewBytes = null; + }); + + await WidgetsBinding.instance.endOfFrame; + if (!mounted || generation != _finalPreviewGeneration) return; + try { - final rotated = rotateImage(base, _rotationQuarterTurns); - final adjusted = adjustBrightnessContrast( - rotated, - brightness: _brightness, - contrast: _contrast, + final queuedPreview = await _enqueueWork( + canStart: () => mounted && generation == _finalPreviewGeneration, + work: () => widget.operations.buildFinalPreview( + base, + rotationQuarterTurns: rotationQuarterTurns, + brightness: brightness, + contrast: contrast, + ), ); - if (!mounted) return; - setState(() => _finalPreviewBytes = adjusted); + if (!queuedPreview.started || + !mounted || + generation != _finalPreviewGeneration) { + return; + } + setState(() { + _finalPreviewBytes = queuedPreview.value!; + _isGeneratingFinalPreview = false; + }); } catch (_) { - // Same reasoning as _updatePreviews: this is preview-only, Confirm - // recomputes from scratch if something's off. + if (!mounted || generation != _finalPreviewGeneration) return; + // This is preview-only; Confirm recomputes from scratch if it fails. + setState(() => _isGeneratingFinalPreview = false); } } @@ -307,17 +489,29 @@ class _CornerAdjustScreenState extends State { final rotationQuarterTurns = _rotationQuarterTurns; final brightness = _brightness; final contrast = _contrast; - setState(() => _isProcessing = true); + _previewGeneration++; + _finalPreviewGeneration++; + final exportGeneration = ++_exportGeneration; + setState(() { + _isProcessing = true; + _isGeneratingPreviews = false; + _isGeneratingFinalPreview = false; + }); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; try { - final processed = await widget.operations.processForExport( - widget.originalBytes, - corners, - filter: filter, - rotationQuarterTurns: rotationQuarterTurns, - brightness: brightness, - contrast: contrast, + final queuedExport = await _enqueueWork( + canStart: () => mounted && exportGeneration == _exportGeneration, + work: () => widget.operations.processForExport( + widget.originalBytes, + corners, + filter: filter, + rotationQuarterTurns: rotationQuarterTurns, + brightness: brightness, + contrast: contrast, + ), ); - if (!mounted) return; + if (!queuedExport.started || !mounted) return; final page = ScannedPage( originalBytes: widget.originalBytes, corners: corners, @@ -325,7 +519,7 @@ class _CornerAdjustScreenState extends State { rotationQuarterTurns: rotationQuarterTurns, brightness: brightness, contrast: contrast, - processedBytes: processed, + processedBytes: queuedExport.value!, ); // Otherwise Navigator.pop would consume the local filter history entry // instead of completing this route with the scanned page. @@ -340,7 +534,7 @@ class _CornerAdjustScreenState extends State { void _rotate() { setState(() => _rotationQuarterTurns = (_rotationQuarterTurns + 1) % 4); - _updateFinalPreview(); + unawaited(_updateFinalPreview()); } bool get _isEditingExistingPage => widget.initialCorners != null; @@ -399,7 +593,7 @@ class _CornerAdjustScreenState extends State { corners: corners, onChanged: (c) { setState(() => _corners = c); - _updatePreviews(); + unawaited(_updatePreviews()); }, ), ), @@ -459,13 +653,19 @@ class _CornerAdjustScreenState extends State { Expanded( child: Padding( padding: const EdgeInsets.all(16), - child: previewBytes != null - ? _boundedPreviewImage( + child: Stack( + fit: StackFit.expand, + children: [ + if (previewBytes != null) + _boundedPreviewImage( previewBytes, fit: BoxFit.contain, maxDimension: _fullPreviewDecodeSize, - ) - : const Center(child: CircularProgressIndicator()), + ), + if (previewBytes == null || _isGeneratingFinalPreview) + const Center(child: CircularProgressIndicator()), + ], + ), ), ), Padding( @@ -483,7 +683,7 @@ class _CornerAdjustScreenState extends State { : (v) => setState(() => _brightness = v), onChangeEnd: _isProcessing ? null - : (_) => _updateFinalPreview(), + : (_) => unawaited(_updateFinalPreview()), ), ), ], @@ -504,7 +704,7 @@ class _CornerAdjustScreenState extends State { : (v) => setState(() => _contrast = v), onChangeEnd: _isProcessing ? null - : (_) => _updateFinalPreview(), + : (_) => unawaited(_updateFinalPreview()), ), ), ], @@ -565,7 +765,7 @@ class _CornerAdjustScreenState extends State { ? null : () { setState(() => _selectedFilter = filter); - _updateFinalPreview(); + unawaited(_updateFinalPreview()); }, child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index bc83bc6..e0c0e1c 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -28,17 +28,23 @@ class _DocumentCapacityException implements Exception { enum _PhotoIntakeResult { added, skipped, capacityReached } +typedef SourceImageSizeReader = Future Function(Uint8List imageBytes); + class ScannerHomePage extends StatefulWidget { const ScannerHomePage({ super.key, this.initialPages = const [], this.sharePlus, this.draftStore = const NoOpDraftStore(), + this.cornerAdjustOperations = const DefaultCornerAdjustOperations(), + this.sourceImageSizeReader = readEncodedImageSize, }); final List initialPages; final SharePlus? sharePlus; final DraftStore draftStore; + final CornerAdjustOperations cornerAdjustOperations; + final SourceImageSizeReader sourceImageSizeReader; @override State createState() => _ScannerHomePageState(); @@ -54,6 +60,7 @@ class _ScannerHomePageState extends State { late final List _pages; late final SharePlus _sharePlus; final ImagePicker _picker = ImagePicker(); + final ImageProcessingQueue _imageProcessingQueue = ImageProcessingQueue(); Future _draftWriteTail = Future.value(); var _draftRevision = 0; var _documentGeneration = 0; @@ -62,6 +69,7 @@ class _ScannerHomePageState extends State { bool _isGeneratingPdf = false; bool _isPickingImages = false; bool _isClearingDraft = false; + bool _isOpeningEditor = false; late bool _cameraSupported; @override @@ -382,6 +390,22 @@ class _ScannerHomePageState extends State { } } + CornerAdjustScreen _buildEditor( + Uint8List originalBytes, { + ScannedPage? page, + }) { + return CornerAdjustScreen( + originalBytes: originalBytes, + initialCorners: page?.corners, + initialFilter: page?.filter ?? PageFilter.original, + initialRotationQuarterTurns: page?.rotationQuarterTurns ?? 0, + initialBrightness: page?.brightness ?? 0, + initialContrast: page?.contrast ?? 1, + operations: widget.cornerAdjustOperations, + processingQueue: _imageProcessingQueue, + ); + } + /// Shared by camera capture and gallery import: read the file's bytes, /// optionally delete the source file, then (native only) run the photo /// through the detect/adjust flow before adding it as a page. @@ -459,8 +483,8 @@ class _ScannerHomePageState extends State { validateSourceImageSize(imageSize); } on UnsupportedError { _showMessage( - 'This image has dimensions too large to process safely. Choose an ' - 'image up to ${maxSourceImageEdge}px per edge and ' + 'This image has unsupported dimensions. Choose an image from 3x3 ' + 'up to ${maxSourceImageEdge}px per edge and ' '$maxSourceImagePixels pixels.', ); return _PhotoIntakeResult.skipped; @@ -490,11 +514,21 @@ class _ScannerHomePageState extends State { : _PhotoIntakeResult.capacityReached; } - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CornerAdjustScreen(originalBytes: bytes), - ), - ); + if (!canProcessSourceImage( + currentRetainedBytes: _retainedDocumentBytes, + encodedBytes: bytes.length, + size: imageSize, + )) { + _showMessage( + 'This image needs too much temporary memory to process safely. ' + 'Remove pages or choose a smaller image.', + ); + return _PhotoIntakeResult.skipped; + } + + final result = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => _buildEditor(bytes))); if (result == null || !mounted || _isClearingDraft || @@ -508,44 +542,97 @@ class _ScannerHomePageState extends State { Future _editPage(int index) async { // No detect/adjust flow on web (see _addCapturedPhoto) — nothing to edit. - if (kIsWeb || _isClearingDraft || index >= _pages.length) return; + if (kIsWeb || + _isClearingDraft || + _isOpeningEditor || + index < 0 || + index >= _pages.length) { + return; + } - final page = _pages[index]; - final documentGeneration = _documentGeneration; - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CornerAdjustScreen( - originalBytes: page.originalBytes, - initialCorners: page.corners, - initialFilter: page.filter, - initialRotationQuarterTurns: page.rotationQuarterTurns, - initialBrightness: page.brightness, - initialContrast: page.contrast, - ), - ), - ); - if (result != null && - mounted && - !_isClearingDraft && - documentGeneration == _documentGeneration && - index < _pages.length && - identical(_pages[index], page)) { - if (!canReplaceDocumentPage( - currentBytes: _retainedDocumentBytes, - currentPages: _pages.length, - replacedBytes: _pageMemoryBytes(page), - replacementBytes: _pageMemoryBytes(result), + // Acquire this home page's editor lock before metadata loading yields. + // This blocks stale, rapid card taps from starting overlapping routes. + setState(() => _isOpeningEditor = true); + try { + final page = _pages[index]; + final documentGeneration = _documentGeneration; + bool requestIsCurrent() => + mounted && + !_isClearingDraft && + documentGeneration == _documentGeneration && + index < _pages.length && + identical(_pages[index], page); + + late final Size imageSize; + try { + imageSize = await widget.sourceImageSizeReader(page.originalBytes); + validateSourceImageSize(imageSize); + } on UnsupportedError { + if (!requestIsCurrent()) return; + _showMessage( + 'This image has unsupported dimensions. Choose an image from 3x3 ' + 'up to ${maxSourceImageEdge}px per edge and ' + '$maxSourceImagePixels pixels.', + ); + return; + } catch (_) { + if (requestIsCurrent()) _showMessage('Could not read this photo.'); + return; + } + if (!requestIsCurrent()) return; + if (!canProcessSourceImage( + currentRetainedBytes: _retainedDocumentBytes, + // The source is already part of the retained document total. + encodedBytes: 0, + size: imageSize, )) { - _showDocumentLimit(); + _showMessage( + 'This image needs too much temporary memory to process safely. ' + 'Remove pages or choose a smaller image.', + ); return; } - setState(() => _pages[index] = result); - _queueDraftSave(); + if (!mounted) return; + + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _buildEditor(page.originalBytes, page: page), + ), + ); + if (result != null && + mounted && + !_isClearingDraft && + documentGeneration == _documentGeneration && + index < _pages.length && + identical(_pages[index], page)) { + if (!canReplaceDocumentPage( + currentBytes: _retainedDocumentBytes, + currentPages: _pages.length, + replacedBytes: _pageMemoryBytes(page), + replacementBytes: _pageMemoryBytes(result), + )) { + _showDocumentLimit(); + return; + } + setState(() => _pages[index] = result); + _queueDraftSave(); + } + } finally { + if (mounted) { + setState(() => _isOpeningEditor = false); + } else { + _isOpeningEditor = false; + } } } void _removePage(int index) { - if (_isClearingDraft || index < 0 || index >= _pages.length) return; + if (_isClearingDraft || + _isOpeningEditor || + index < 0 || + index >= _pages.length) { + return; + } final removed = _pages[index]; final undoGeneration = ++_undoGeneration; final messenger = ScaffoldMessenger.of(context); @@ -582,6 +669,7 @@ class _ScannerHomePageState extends State { void _reorderPage(int fromIndex, int toIndex) { if (_isClearingDraft || + _isOpeningEditor || fromIndex < 0 || fromIndex >= _pages.length || toIndex < 0 || @@ -841,7 +929,9 @@ class _ScannerHomePageState extends State { final card = Card( clipBehavior: Clip.antiAlias, child: InkWell( - onTap: _isClearingDraft ? null : () => _editPage(index), + onTap: _isClearingDraft || _isOpeningEditor + ? null + : () => _editPage(index), child: Stack( fit: StackFit.expand, children: [ @@ -885,7 +975,7 @@ class _ScannerHomePageState extends State { color: Colors.white, ), tooltip: 'Delete page ${index + 1}', - onPressed: _isClearingDraft + onPressed: _isClearingDraft || _isOpeningEditor ? null : () => _removePage(index), ), @@ -900,14 +990,18 @@ class _ScannerHomePageState extends State { // on another page's slot to swap it into that position. return DragTarget( onWillAcceptWithDetails: (details) => - !_isClearingDraft && details.data != index, + !_isClearingDraft && + !_isOpeningEditor && + details.data != index, onAcceptWithDetails: (details) => _reorderPage(details.data, index), builder: (context, candidateData, rejectedData) { final isDropTarget = candidateData.isNotEmpty; return LongPressDraggable( data: index, - maxSimultaneousDrags: _isClearingDraft ? 0 : 1, + maxSimultaneousDrags: _isClearingDraft || _isOpeningEditor + ? 0 + : 1, feedback: SizedBox( width: 140, height: 200, diff --git a/lib/services/image_metadata.dart b/lib/services/image_metadata.dart index 609200b..e6a5ae5 100644 --- a/lib/services/image_metadata.dart +++ b/lib/services/image_metadata.dart @@ -2,6 +2,8 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'corner_geometry.dart' show maxWarpPixels; + /// Bound source decoding before document processing. Image decoders expand /// compressed inputs into multi-byte pixel buffers, so compressed file size /// alone is not a safe allocation limit. @@ -11,6 +13,13 @@ const maxPickerEdge = 4472.0; const maxEncodedImageBytes = 32 * 1024 * 1024; const maxDocumentPages = 100; const maxRetainedDocumentBytes = 256 * 1024 * 1024; +const maxImageProcessingWorkingSetBytes = 256 * 1024 * 1024; + +// Peak processing allowance: the source decoder retains one four-channel +// buffer while export/auto-enhance can hold four four-channel warp-sized +// intermediates. Warp allocations are bounded by maxWarpPixels. +const _sourceDecodeBytesPerPixel = 4; +const _warpIntermediateBytesPerPixel = 16; class EncodedImageTooLargeError extends UnsupportedError { EncodedImageTooLargeError(int maxBytes) @@ -106,6 +115,15 @@ void validateSourceImageSize(ui.Size size) { throw const FormatException('Image dimensions must be finite and positive'); } + if (size.width != size.width.truncateToDouble() || + size.height != size.height.truncateToDouble()) { + throw const FormatException('Image dimensions must be whole pixels'); + } + + if (size.width < 3 || size.height < 3) { + throw UnsupportedError('Image dimensions must be at least 3x3 pixels'); + } + if (size.width > maxSourceImageEdge || size.height > maxSourceImageEdge || size.width * size.height > maxSourceImagePixels) { @@ -115,3 +133,43 @@ void validateSourceImageSize(ui.Size size) { ); } } + +int estimateDecodedOpenCvBytes(ui.Size size) { + validateSourceImageSize(size); + final sourcePixels = size.width.toInt() * size.height.toInt(); + final warpPixels = math.min(sourcePixels, maxWarpPixels); + return sourcePixels * _sourceDecodeBytesPerPixel + + warpPixels * _warpIntermediateBytesPerPixel; +} + +int estimateImageProcessingWorkingSet({ + required int currentRetainedBytes, + required int encodedBytes, + required ui.Size size, +}) { + if (currentRetainedBytes < 0 || encodedBytes < 0) { + throw ArgumentError('Image processing byte counters must not be negative'); + } + return currentRetainedBytes + encodedBytes + estimateDecodedOpenCvBytes(size); +} + +bool canProcessSourceImage({ + required int currentRetainedBytes, + required int encodedBytes, + required ui.Size size, + int maxWorkingSetBytes = maxImageProcessingWorkingSetBytes, +}) { + if (maxWorkingSetBytes < 0) { + throw ArgumentError.value( + maxWorkingSetBytes, + 'maxWorkingSetBytes', + 'Must not be negative', + ); + } + return estimateImageProcessingWorkingSet( + currentRetainedBytes: currentRetainedBytes, + encodedBytes: encodedBytes, + size: size, + ) <= + maxWorkingSetBytes; +} diff --git a/test/screens/corner_adjust_async_test.dart b/test/screens/corner_adjust_async_test.dart new file mode 100644 index 0000000..6d39200 --- /dev/null +++ b/test/screens/corner_adjust_async_test.dart @@ -0,0 +1,392 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fosscanner/models/scanned_page.dart'; +import 'package:fosscanner/screens/corner_adjust_screen.dart'; +import 'package:fosscanner/screens/scanner_home_page.dart'; +import 'package:fosscanner/widgets/corner_overlay.dart'; + +const _corners = [Offset(5, 5), Offset(95, 5), Offset(95, 95), Offset(5, 95)]; + +Future _iconBytes() async { + final data = await rootBundle.load('assets/icon/icon.png'); + return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); +} + +Map _previews(Uint8List bytes) => { + for (final filter in PageFilter.values) filter: bytes, +}; + +class _ControlledOperations implements CornerAdjustOperations { + _ControlledOperations({ + required this.detectResult, + this.completeFinalImmediately = false, + }); + + final List? detectResult; + final bool completeFinalImmediately; + final detectStarted = Completer(); + final decodeCompleted = Completer(); + final previewStarted = >[ + for (var i = 0; i < 4; i++) Completer(), + ]; + final finalStarted = >[ + for (var i = 0; i < 4; i++) Completer(), + ]; + final previewResults = >>[]; + final finalResults = >[]; + final exportResults = >[]; + final previewCorners = >[]; + var activeWorkers = 0; + var maxConcurrentWorkers = 0; + + Future _trackWorker(Future future) { + activeWorkers++; + if (activeWorkers > maxConcurrentWorkers) { + maxConcurrentWorkers = activeWorkers; + } + return future.whenComplete(() => activeWorkers--); + } + + @override + Future decodeSize(Uint8List imageBytes) async { + if (!decodeCompleted.isCompleted) decodeCompleted.complete(); + return const Size(100, 100); + } + + @override + Future?> detectCorners(Uint8List imageBytes) { + detectStarted.complete(); + return _trackWorker(Future.value(detectResult)); + } + + @override + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ) { + final index = previewResults.length; + final result = Completer>(); + previewResults.add(result); + previewCorners.add(List.of(corners)); + previewStarted[index].complete(); + return _trackWorker(result.future); + } + + @override + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) { + if (completeFinalImmediately) { + return _trackWorker(Future.value(imageBytes)); + } + final index = finalResults.length; + final result = Completer(); + finalResults.add(result); + finalStarted[index].complete(); + return _trackWorker(result.future); + } + + @override + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) { + final result = Completer(); + exportResults.add(result); + return _trackWorker(result.future); + } +} + +Future _waitForPreviewStart( + WidgetTester tester, + _ControlledOperations operations, + int index, +) async { + await tester.pump(); + await tester.pump(); + expect(operations.previewStarted[index].isCompleted, isTrue); +} + +Future _waitForFinalStart( + WidgetTester tester, + _ControlledOperations operations, + int index, +) async { + await tester.pump(); + await tester.pump(); + expect(operations.finalStarted[index].isCompleted, isTrue); +} + +void main() { + testWidgets('uses injectable corner detection before generating previews', ( + tester, + ) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations( + detectResult: _corners, + completeFinalImmediately: true, + ); + + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen(originalBytes: bytes, operations: operations), + ), + ); + await tester.pump(); + expect(operations.detectStarted.isCompleted, isTrue); + await _waitForPreviewStart(tester, operations, 0); + + expect( + tester.widget(find.byType(CornerOverlay)).corners, + _corners, + ); + operations.previewResults.single.complete(_previews(bytes)); + await tester.pump(); + }); + + testWidgets('second preview worker waits for the active preview', ( + tester, + ) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations( + detectResult: _corners, + completeFinalImmediately: true, + ); + const updatedCorners = [ + Offset(10, 10), + Offset(90, 10), + Offset(90, 90), + Offset(10, 90), + ]; + + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen( + originalBytes: bytes, + initialCorners: _corners, + operations: operations, + ), + ), + ); + await _waitForPreviewStart(tester, operations, 0); + tester + .widget(find.byType(CornerOverlay)) + .onChanged(updatedCorners); + await tester.pump(); + await tester.pump(); + + expect(operations.previewResults, hasLength(1)); + expect(operations.activeWorkers, 1); + + operations.previewResults[0].complete(_previews(bytes)); + await _waitForPreviewStart(tester, operations, 1); + + expect(operations.previewCorners[1], updatedCorners); + expect(operations.maxConcurrentWorkers, 1); + operations.previewResults[1].complete(_previews(bytes)); + await tester.pump(); + await tester.pump(); + }); + + testWidgets('stale queued preview is skipped in favor of the newest', ( + tester, + ) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations( + detectResult: _corners, + completeFinalImmediately: true, + ); + const staleCorners = [ + Offset(8, 8), + Offset(92, 8), + Offset(92, 92), + Offset(8, 92), + ]; + const newestCorners = [ + Offset(12, 12), + Offset(88, 12), + Offset(88, 88), + Offset(12, 88), + ]; + + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen( + originalBytes: bytes, + initialCorners: _corners, + operations: operations, + ), + ), + ); + await _waitForPreviewStart(tester, operations, 0); + final overlay = tester.widget(find.byType(CornerOverlay)); + overlay.onChanged(staleCorners); + await tester.pump(); + await tester.pump(); + tester + .widget(find.byType(CornerOverlay)) + .onChanged(newestCorners); + await tester.pump(); + await tester.pump(); + + expect(operations.previewResults, hasLength(1)); + operations.previewResults.single.complete(_previews(bytes)); + await _waitForPreviewStart(tester, operations, 1); + + expect(operations.previewResults, hasLength(2)); + expect(operations.previewCorners, [_corners, newestCorners]); + expect(operations.maxConcurrentWorkers, 1); + operations.previewResults[1].complete(_previews(bytes)); + await tester.pump(); + await tester.pump(); + }); + + testWidgets( + 'confirm skips stale final work and exports after active worker', + (tester) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations(detectResult: _corners); + + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen( + originalBytes: bytes, + initialCorners: _corners, + operations: operations, + ), + ), + ); + await _waitForPreviewStart(tester, operations, 0); + operations.previewResults.single.complete(_previews(bytes)); + await _waitForFinalStart(tester, operations, 0); + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.tap(find.text('Gray')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('Confirm')); + await tester.pump(); + await tester.pump(); + + expect(operations.finalResults, hasLength(1)); + expect(operations.exportResults, isEmpty); + expect(operations.activeWorkers, 1); + + operations.finalResults.single.complete(bytes); + await tester.pump(); + await tester.pump(); + + expect(operations.finalResults, hasLength(1)); + expect(operations.exportResults, hasLength(1)); + expect(operations.activeWorkers, 1); + expect(operations.maxConcurrentWorkers, 1); + + operations.exportResults.single.complete(bytes); + await tester.pump(); + }, + ); + + testWidgets('home editors share work after a route is disposed', ( + tester, + ) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations( + detectResult: _corners, + completeFinalImmediately: true, + ); + final page = ScannedPage( + originalBytes: bytes, + corners: _corners, + processedBytes: bytes, + ); + + await tester.pumpWidget( + MaterialApp( + home: ScannerHomePage( + initialPages: [page, page], + cornerAdjustOperations: operations, + sourceImageSizeReader: (_) async => const Size(100, 100), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Card).at(0)); + await _waitForPreviewStart(tester, operations, 0); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Card).at(1)); + await tester.pump(); + await tester.pump(); + + expect( + operations.previewResults, + hasLength(1), + reason: 'replacement routes must share the home screen worker queue', + ); + + operations.previewResults[0].complete(_previews(bytes)); + await _waitForPreviewStart(tester, operations, 1); + expect(operations.maxConcurrentWorkers, 1); + operations.previewResults[1].complete(_previews(bytes)); + await tester.pump(); + await tester.pageBack(); + await tester.pumpAndSettle(); + }); + + testWidgets('disposing with pending preview work is safe', (tester) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations(detectResult: _corners); + + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen( + originalBytes: bytes, + initialCorners: _corners, + operations: operations, + ), + ), + ); + await _waitForPreviewStart(tester, operations, 0); + await tester.pumpWidget(const SizedBox()); + operations.previewResults.single.complete(_previews(bytes)); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('disposing with pending final-preview work is safe', ( + tester, + ) async { + final bytes = await _iconBytes(); + final operations = _ControlledOperations(detectResult: _corners); + + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen( + originalBytes: bytes, + initialCorners: _corners, + operations: operations, + ), + ), + ); + await _waitForPreviewStart(tester, operations, 0); + operations.previewResults.single.complete(_previews(bytes)); + await _waitForFinalStart(tester, operations, 0); + await tester.pumpWidget(const SizedBox()); + operations.finalResults.single.complete(bytes); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/corner_adjust_navigation_test.dart b/test/screens/corner_adjust_navigation_test.dart index 04105ea..3234904 100644 --- a/test/screens/corner_adjust_navigation_test.dart +++ b/test/screens/corner_adjust_navigation_test.dart @@ -15,12 +15,23 @@ class _Operations implements CornerAdjustOperations { @override Future decodeSize(Uint8List imageBytes) async => const Size(100, 100); + @override + Future?> detectCorners(Uint8List imageBytes) async => null; + @override Future> buildPreviews( Uint8List imageBytes, List corners, ) async => {for (final filter in PageFilter.values) filter: bytes}; + @override + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => imageBytes; + @override Future processForExport( Uint8List imageBytes, diff --git a/test/screens/invalid_corner_geometry_test.dart b/test/screens/invalid_corner_geometry_test.dart index d1f74c8..986b32f 100644 --- a/test/screens/invalid_corner_geometry_test.dart +++ b/test/screens/invalid_corner_geometry_test.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fosscanner/models/scanned_page.dart'; import 'package:fosscanner/screens/corner_adjust_screen.dart'; Future _iconBytes() async { @@ -8,18 +11,54 @@ Future _iconBytes() async { return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); } -Future _pumpUntilReady(WidgetTester tester) async { - for (var i = 0; i < 20 && find.text('Next').evaluate().isEmpty; i++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(); +class _Operations implements CornerAdjustOperations { + _Operations(this.bytes); + + final Uint8List bytes; + final decodeCompleted = Completer(); + final previewsRequested = Completer(); + + @override + Future decodeSize(Uint8List imageBytes) async { + decodeCompleted.complete(); + return const Size(100, 100); + } + + @override + Future?> detectCorners(Uint8List imageBytes) async => null; + + @override + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ) async { + if (!previewsRequested.isCompleted) previewsRequested.complete(); + return {for (final filter in PageFilter.values) filter: bytes}; } + + @override + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => imageBytes; + + @override + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => bytes; } void main() { testWidgets('an invalid crop cannot enter the preview step', (tester) async { final bytes = await _iconBytes(); + final operations = _Operations(bytes); await tester.pumpWidget( MaterialApp( @@ -31,13 +70,19 @@ void main() { Offset(95, 95), Offset(5, 95), ], + operations: operations, ), ), ); - await _pumpUntilReady(tester); + await tester.pump(); + expect(operations.decodeCompleted.isCompleted, isTrue); + await tester.pumpAndSettle(); expect(find.text('Next'), findsOneWidget); - await tester.tap(find.text('Next')); + expect(operations.previewsRequested.isCompleted, isFalse); + tester + .widget(find.widgetWithText(FilledButton, 'Next')) + .onPressed!(); await tester.pump(); expect(find.text('Edit page'), findsOneWidget); @@ -49,16 +94,22 @@ void main() { tester, ) async { final bytes = await _iconBytes(); + final operations = _Operations(bytes); await tester.pumpWidget( MaterialApp( home: CornerAdjustScreen( originalBytes: bytes, initialCorners: const [Offset.zero, Offset(100, 0), Offset(100, 100)], + operations: operations, ), ), ); - await _pumpUntilReady(tester); + await tester.pump(); + expect(operations.decodeCompleted.isCompleted, isTrue); + await tester.pump(); + expect(operations.previewsRequested.isCompleted, isTrue); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.text('Next'), findsOneWidget); diff --git a/test/services/document_processor_native_test.dart b/test/services/document_processor_native_test.dart index f87a8d5..432d24c 100644 --- a/test/services/document_processor_native_test.dart +++ b/test/services/document_processor_native_test.dart @@ -3,52 +3,220 @@ library; import 'dart:io'; import 'dart:isolate'; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:fosscanner/models/scanned_page.dart'; -import 'package:opencv_dart/opencv_dart.dart' as cv; import 'package:fosscanner/services/document_processor_native.dart'; +import 'package:opencv_dart/opencv_dart.dart' as cv; + +Future _drawPng( + int width, + int height, + void Function(Canvas canvas) draw, +) async { + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + draw(canvas); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + try { + final data = await image.toByteData(format: ImageByteFormat.png); + return data!.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } finally { + image.dispose(); + } +} + +Future<({int width, int height, int red, int green, int blue})> _samplePixel( + Uint8List bytes, + int x, + int y, +) async { + final codec = await instantiateImageCodec(bytes); + FrameInfo? frame; + try { + frame = await codec.getNextFrame(); + final data = await frame.image.toByteData(format: ImageByteFormat.rawRgba); + final rgba = data!.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + final offset = (y * frame.image.width + x) * 4; + return ( + width: frame.image.width, + height: frame.image.height, + red: rgba[offset], + green: rgba[offset + 1], + blue: rgba[offset + 2], + ); + } finally { + frame?.image.dispose(); + codec.dispose(); + } +} void main() { - test('warpDocument forwards an explicit output budget', () { + test('detectCorners finds a synthetic high-contrast rectangle', () async { + final input = await _drawPng(600, 400, (canvas) { + canvas.drawColor(const Color(0xff000000), BlendMode.src); + canvas.drawRect( + const Rect.fromLTRB(80, 60, 520, 340), + Paint()..color = const Color(0xffffffff), + ); + }); + + final corners = detectCorners(input); + + expect(corners, isNotNull); + const expected = [ + Offset(80, 60), + Offset(519, 60), + Offset(519, 339), + Offset(80, 339), + ]; + for (var i = 0; i < expected.length; i++) { + expect( + (corners![i] - expected[i]).distance, + lessThan(12), + reason: 'corner $i', + ); + } + }); + + test('warpDocument produces a useful result within a custom budget', () { final input = File('assets/icon/icon.png').readAsBytesSync(); final output = warpDocument( input, - const [ - Offset.zero, - Offset(10000, 0), - Offset(10000, 5000), - Offset(0, 5000), - ], - maxPixels: 100000, - maxEdge: 400, + const [Offset.zero, Offset(799, 0), Offset(799, 399), Offset(0, 399)], + maxPixels: 50000, + maxEdge: 300, ); final decoded = cv.imdecode(output, cv.IMREAD_COLOR); try { - expect(decoded.cols * decoded.rows, lessThanOrEqualTo(100000)); - expect(decoded.cols, lessThanOrEqualTo(400)); - expect(decoded.rows, lessThanOrEqualTo(400)); + expect(decoded.cols, 300); + expect(decoded.rows, 150); + expect(decoded.cols / decoded.rows, closeTo(2, 0.02)); + expect(decoded.cols * decoded.rows, greaterThanOrEqualTo(40000)); + expect(decoded.cols * decoded.rows, lessThanOrEqualTo(50000)); } finally { decoded.dispose(); } }); + test('warpDocument preserves nonuniform source-corner orientation', () async { + final input = await _drawPng(160, 120, (canvas) { + canvas.drawRect( + const Rect.fromLTWH(0, 0, 80, 60), + Paint()..color = const Color(0xffff0000), + ); + canvas.drawRect( + const Rect.fromLTWH(80, 0, 80, 60), + Paint()..color = const Color(0xff00ff00), + ); + canvas.drawRect( + const Rect.fromLTWH(80, 60, 80, 60), + Paint()..color = const Color(0xff0000ff), + ); + canvas.drawRect( + const Rect.fromLTWH(0, 60, 80, 60), + Paint()..color = const Color(0xffffff00), + ); + }); + + final output = warpDocument(input, const [ + Offset(20, 10), + Offset(145, 25), + Offset(130, 110), + Offset(30, 95), + ]); + final dimensions = await _samplePixel(output, 5, 5); + final topRight = await _samplePixel(output, dimensions.width - 6, 5); + final bottomRight = await _samplePixel( + output, + dimensions.width - 6, + dimensions.height - 6, + ); + final bottomLeft = await _samplePixel(output, 5, dimensions.height - 6); + + expect(dimensions.red, greaterThan(dimensions.green + 80)); + expect(dimensions.red, greaterThan(dimensions.blue + 80)); + expect(topRight.green, greaterThan(topRight.red + 80)); + expect(topRight.green, greaterThan(topRight.blue + 80)); + expect(bottomRight.blue, greaterThan(bottomRight.red + 80)); + expect(bottomRight.blue, greaterThan(bottomRight.green + 80)); + expect(bottomLeft.red, greaterThan(bottomLeft.blue + 80)); + expect(bottomLeft.green, greaterThan(bottomLeft.blue + 80)); + }); + + test('grayscale creates independently verifiable neutral pixels', () async { + final input = await _drawPng(40, 30, (canvas) { + canvas.drawColor(const Color.fromARGB(255, 30, 90, 150), BlendMode.src); + }); + + final pixel = await _samplePixel( + applyFilter(input, PageFilter.grayscale), + 20, + 15, + ); + + expect((pixel.red - pixel.green).abs(), lessThanOrEqualTo(3)); + expect((pixel.green - pixel.blue).abs(), lessThanOrEqualTo(3)); + expect(pixel.red, closeTo(79, 8)); + }); + + test( + 'brightness changes pixels by the requested independent amount', + () async { + final input = await _drawPng(40, 30, (canvas) { + canvas.drawColor(const Color.fromARGB(255, 50, 50, 50), BlendMode.src); + }); + + final pixel = await _samplePixel( + adjustBrightnessContrast(input, brightness: 40, contrast: 1), + 20, + 15, + ); + + expect(pixel.red, closeTo(90, 8)); + expect(pixel.green, closeTo(90, 8)); + expect(pixel.blue, closeTo(90, 8)); + }, + ); + + test('clockwise rotation moves the left half to the top', () async { + final input = await _drawPng(40, 20, (canvas) { + canvas.drawRect( + const Rect.fromLTWH(0, 0, 20, 20), + Paint()..color = const Color(0xffff0000), + ); + canvas.drawRect( + const Rect.fromLTWH(20, 0, 20, 20), + Paint()..color = const Color(0xff0000ff), + ); + }); + + final output = rotateImage(input, 1); + final top = await _samplePixel(output, 10, 5); + final bottom = await _samplePixel(output, 10, 35); + + expect((top.width, top.height), (20, 40)); + expect(top.red, greaterThan(top.blue + 100)); + expect(bottom.blue, greaterThan(bottom.red + 100)); + }); + test('the export pipeline can run in a worker isolate', () async { - final input = File('assets/icon/icon.png').readAsBytesSync(); + final input = await _drawPng(200, 100, (canvas) { + canvas.drawColor(const Color.fromARGB(255, 30, 90, 150), BlendMode.src); + }); const corners = [ Offset.zero, - Offset(1023, 0), - Offset(1023, 511), - Offset(0, 511), + Offset(199, 0), + Offset(199, 99), + Offset(0, 99), ]; - final expected = adjustBrightnessContrast( - rotateImage( - applyFilter(warpDocument(input, corners), PageFilter.grayscale), - 1, - ), - brightness: 10, - contrast: 1.1, - ); final output = await Isolate.run( () => processDocument( @@ -56,18 +224,16 @@ void main() { corners, filter: PageFilter.grayscale, rotationQuarterTurns: 1, - brightness: 10, - contrast: 1.1, + brightness: 20, + contrast: 1, ), ); - final decoded = cv.imdecode(output, cv.IMREAD_COLOR); - try { - expect(output, orderedEquals(expected)); - expect(decoded.cols, 511); - expect(decoded.rows, 1023); - } finally { - decoded.dispose(); - } + final pixel = await _samplePixel(output, 49, 99); + + expect((pixel.width, pixel.height), (99, 199)); + expect((pixel.red - pixel.green).abs(), lessThanOrEqualTo(3)); + expect((pixel.green - pixel.blue).abs(), lessThanOrEqualTo(3)); + expect(pixel.red, closeTo(99, 12)); }); test('auto-enhance repeatedly returns a decodable image', () { diff --git a/test/services/draft_store_native_test.dart b/test/services/draft_store_native_test.dart index 6532e26..c80b31b 100644 --- a/test/services/draft_store_native_test.dart +++ b/test/services/draft_store_native_test.dart @@ -7,10 +7,7 @@ import 'package:fosscanner/services/draft_store_native.dart'; void main() { late Directory directory; - final tinyPng = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk' - '+A8AAQUBAScY42YAAAAASUVORK5CYII=', - ); + final tinyPng = File('assets/icon/icon.png').readAsBytesSync(); setUp(() async { directory = Directory('.draft-store-test-tmp'); diff --git a/test/services/image_metadata_test.dart b/test/services/image_metadata_test.dart index a83e159..da178af 100644 --- a/test/services/image_metadata_test.dart +++ b/test/services/image_metadata_test.dart @@ -95,15 +95,144 @@ void main() { ); }); - test('rejects source images whose decoded allocation is too large', () { + test('accepts source dimensions exactly at the edge-only boundary', () { expect( - () => validateSourceImageSize( - const Size(maxSourceImageEdge + 1, maxSourceImageEdge + 1), - ), + () => validateSourceImageSize(Size(maxSourceImageEdge.toDouble(), 3)), + returnsNormally, + ); + expect( + () => + validateSourceImageSize(Size((maxSourceImageEdge + 1).toDouble(), 3)), throwsA(isA()), ); }); + test('accepts source dimensions exactly at the pixel-only boundary', () { + expect( + () => validateSourceImageSize(const Size(4000, 5000)), + returnsNormally, + ); + expect( + () => validateSourceImageSize(const Size(4001, 5000)), + throwsA(isA()), + ); + }); + + test('rejects source dimensions below three pixels per edge', () { + expect( + () => validateSourceImageSize(const Size(2, 3)), + throwsA(isA()), + ); + expect( + () => validateSourceImageSize(const Size(3, 2)), + throwsA(isA()), + ); + expect(() => validateSourceImageSize(const Size(3, 3)), returnsNormally); + }); + + test('accepts a compressed 20MP image when processing headroom remains', () { + const encodedBytes = 64 * 1024; + + expect( + estimateImageProcessingWorkingSet( + currentRetainedBytes: 0, + encodedBytes: encodedBytes, + size: const Size(4000, 5000), + ), + encodedBytes + 224000000, + ); + expect( + canProcessSourceImage( + currentRetainedBytes: 0, + encodedBytes: encodedBytes, + size: const Size(4000, 5000), + ), + isTrue, + ); + }); + + test('rejects a compressed 20MP image near the processing budget', () { + expect( + canProcessSourceImage( + currentRetainedBytes: 255 * 1024 * 1024, + encodedBytes: 64 * 1024, + size: const Size(4000, 5000), + ), + isFalse, + ); + }); + + test('20MP processing acceptance follows exact retained headroom', () { + const size = Size(4000, 5000); + const encodedBytes = 64 * 1024; + const transientBytes = encodedBytes + 224000000; + const retainedHeadroom = + maxImageProcessingWorkingSetBytes - transientBytes; + + expect( + canProcessSourceImage( + currentRetainedBytes: retainedHeadroom, + encodedBytes: encodedBytes, + size: size, + ), + isTrue, + ); + expect( + canProcessSourceImage( + currentRetainedBytes: retainedHeadroom + 1, + encodedBytes: encodedBytes, + size: size, + ), + isFalse, + ); + }); + + test('estimates source decode and bounded warp storage independently', () { + expect(estimateDecodedOpenCvBytes(const Size(100, 100)), 200000); + }); + + test('processing estimate includes retained, encoded, and decoded bytes', () { + const size = Size(10, 20); + final decodedEstimate = estimateDecodedOpenCvBytes(size); + + expect( + estimateImageProcessingWorkingSet( + currentRetainedBytes: 11, + encodedBytes: 13, + size: size, + ), + 11 + 13 + decodedEstimate, + ); + expect(decodedEstimate, greaterThan(10 * 20 * 4)); + }); + + test('processing estimator rejects negative counters and dimensions', () { + expect( + () => estimateImageProcessingWorkingSet( + currentRetainedBytes: -1, + encodedBytes: 0, + size: const Size(100, 100), + ), + throwsArgumentError, + ); + expect( + () => estimateImageProcessingWorkingSet( + currentRetainedBytes: 0, + encodedBytes: -1, + size: const Size(100, 100), + ), + throwsArgumentError, + ); + expect( + () => estimateDecodedOpenCvBytes(const Size(-100, 100)), + throwsA(isA()), + ); + expect( + () => estimateDecodedOpenCvBytes(const Size(100.5, 100)), + throwsA(isA()), + ); + }); + test('rejects malformed source dimensions', () { for (final size in [ Size.zero, diff --git a/test/widget_test.dart b/test/widget_test.dart index cd043e8..f9562e8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -83,6 +84,40 @@ class _FakeSharePlatform implements SharePlatform { } } +class _ImmediateCornerOperations implements CornerAdjustOperations { + const _ImmediateCornerOperations(); + + @override + Future decodeSize(Uint8List imageBytes) async => const Size(1024, 1024); + + @override + Future?> detectCorners(Uint8List imageBytes) async => null; + + @override + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ) async => {for (final filter in PageFilter.values) filter: imageBytes}; + + @override + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => imageBytes; + + @override + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => imageBytes; +} + class _TrackingXFile extends XFile { _TrackingXFile(super.path); @@ -128,6 +163,16 @@ class _UnsupportedStreamXFile extends _SizedXFile { Stream.error(UnsupportedError('backend cannot stream')); } +class _TrackingNavigatorObserver extends NavigatorObserver { + var pushCount = 0; + + @override + void didPush(Route route, Route? previousRoute) { + pushCount++; + super.didPush(route, previousRoute); + } +} + class _ThrowingXFile extends XFile { _ThrowingXFile(super.path); @@ -493,12 +538,184 @@ void main() { ); }); + testWidgets('gallery reports a distinct transient processing-budget error', ( + tester, + ) async { + final icon = File('assets/icon/icon.png').readAsBytesSync(); + final retainedBytes = Uint8List(5 * 1024 * 1024) + ..setRange(0, icon.length, icon); + final page = ScannedPage( + originalBytes: retainedBytes, + corners: const [], + processedBytes: retainedBytes, + ); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + images: [_SizedXFile('selection.png', icon)], + ); + + await tester.pumpWidget( + MaterialApp(home: ScannerHomePage(initialPages: List.filled(50, page))), + ); + await tester.pumpAndSettle(); + tester + .widget( + find.widgetWithIcon(IconButton, Icons.photo_library_outlined), + ) + .onPressed!(); + await tester.pumpAndSettle(); + + expect( + find.text( + 'This image needs too much temporary memory to process safely. ' + 'Remove pages or choose a smaller image.', + ), + findsOneWidget, + ); + expect(find.byType(CornerAdjustScreen), findsNothing); + expect(find.textContaining('Document memory limit reached'), findsNothing); + }); + + testWidgets( + 'initial 20MP-metadata page near the cap cannot open the editor', + (tester) async { + final icon = File('assets/icon/icon.png').readAsBytesSync(); + final sharedRetainedBytes = Uint8List(2 * 1024 * 1024) + ..setRange(0, icon.length, icon); + final page = ScannedPage( + originalBytes: sharedRetainedBytes, + corners: const [ + Offset(0, 0), + Offset(3999, 0), + Offset(3999, 4999), + Offset(0, 4999), + ], + processedBytes: sharedRetainedBytes, + ); + Uint8List? inspectedBytes; + + await tester.pumpWidget( + MaterialApp( + home: ScannerHomePage( + initialPages: List.filled(52, page), + sourceImageSizeReader: (bytes) async { + inspectedBytes = bytes; + return const Size(4000, 5000); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Card).first); + await tester.pumpAndSettle(); + + expect(identical(inspectedBytes, sharedRetainedBytes), isTrue); + expect( + find.text( + 'This image needs too much temporary memory to process safely. ' + 'Remove pages or choose a smaller image.', + ), + findsOneWidget, + ); + expect(find.byType(CornerAdjustScreen), findsNothing); + }, + ); + + testWidgets( + 'rapid page taps perform one metadata read and open one editor route', + (tester) async { + final icon = File('assets/icon/icon.png').readAsBytesSync(); + final page = ScannedPage( + originalBytes: icon, + corners: const [], + processedBytes: icon, + ); + final metadata = Completer(); + final observer = _TrackingNavigatorObserver(); + var metadataReads = 0; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: ScannerHomePage( + initialPages: [page], + cornerAdjustOperations: const _ImmediateCornerOperations(), + sourceImageSizeReader: (_) { + metadataReads++; + return metadata.future; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final card = find.byType(Card).first; + + await tester.tap(card); + await tester.tap(card); + expect(metadataReads, 1); + + await tester.pump(); + expect( + tester + .widget( + find.descendant(of: card, matching: find.byType(InkWell)).first, + ) + .onTap, + isNull, + ); + + metadata.complete(const Size(1024, 1024)); + await tester.pumpAndSettle(); + + expect(observer.pushCount, 2); + expect(find.byType(CornerAdjustScreen), findsOneWidget); + final offstageHomeCard = find + .descendant( + of: find.byType(ScannerHomePage, skipOffstage: false), + matching: find.byType(Card, skipOffstage: false), + skipOffstage: false, + ) + .first; + expect( + tester + .widget( + find + .descendant( + of: offstageHomeCard, + matching: find.byType(InkWell, skipOffstage: false), + skipOffstage: false, + ) + .first, + ) + .onTap, + isNull, + ); + + Navigator.of(tester.element(find.byType(CornerAdjustScreen))).pop(); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find + .descendant( + of: find.byType(Card).first, + matching: find.byType(InkWell), + ) + .first, + ) + .onTap, + isNotNull, + ); + }, + ); + testWidgets('gallery reports capacity when a prior selection fills it', ( tester, ) async { final icon = File('assets/icon/icon.png').readAsBytesSync(); const pageCount = 90; - final targetRetainedBytes = maxRetainedDocumentBytes - icon.length; + const additionalProcessedBytes = 24 * 1024 * 1024; + final targetRetainedBytes = + maxRetainedDocumentBytes - icon.length - additionalProcessedBytes; final sharedLength = targetRetainedBytes ~/ pageCount; final sharedBytes = Uint8List(sharedLength); final remainderBytes = Uint8List( @@ -519,7 +736,12 @@ void main() { ImagePickerPlatform.instance = platform; await tester.pumpWidget( - MaterialApp(home: ScannerHomePage(initialPages: pages)), + MaterialApp( + home: ScannerHomePage( + initialPages: pages, + cornerAdjustOperations: const _ImmediateCornerOperations(), + ), + ), ); await tester.pumpAndSettle(); tester @@ -527,16 +749,7 @@ void main() { find.widgetWithIcon(IconButton, Icons.photo_library_outlined), ) .onPressed!(); - for ( - var i = 0; - i < 20 && find.byType(CornerAdjustScreen).evaluate().isEmpty; - i++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(); - } + await tester.pumpAndSettle(); final editor = tester.widget( find.byType(CornerAdjustScreen), ); @@ -544,7 +757,7 @@ void main() { ScannedPage( originalBytes: editor.originalBytes, corners: const [], - processedBytes: editor.originalBytes, + processedBytes: Uint8List(additionalProcessedBytes), ), ); await tester.pumpAndSettle();