import 'dart:developer'; import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:image_blur_detection/image_blur_detection.dart'; /// Longest edge (in pixels) used for quality analysis. /// /// The picked image is downscaled to this size *before* any pixel analysis. /// This is the single most important guard against OOM crashes on low-end /// devices: a 12MP photo decodes to ~48 MB of RGBA, and the Laplacian pass in /// `image_blur_detection` then allocates a growable `List` of one entry /// per pixel (~96 MB, with transient spikes to ~190 MB while the list grows). /// /// At 1024px the same pipeline peaks at roughly 10 MB. const int _kMaxAnalysisDimension = 1024; /// Result of an image quality check. /// /// [analysisFailed] is `true` when the image could not be analysed at all /// (unsupported format, decode failure, out of memory, ...). Callers should /// treat that as "allow the image through" rather than blocking the user. @immutable class ImageQualityCheckResult { final bool isValid; final List issues; final bool analysisFailed; const ImageQualityCheckResult({ required this.isValid, this.issues = const [], this.analysisFailed = false, }); /// Used when analysis is impossible — fails open so the user is never stuck. static const ImageQualityCheckResult skipped = ImageQualityCheckResult( isValid: true, analysisFailed: true, ); } /// Payload sent to the background isolate. @immutable class _AnalysisRequest { final Uint8List pngBytes; final double blurThreshold; final double minBrightness; final double maxBrightness; final double minContrast; const _AnalysisRequest({ required this.pngBytes, required this.blurThreshold, required this.minBrightness, required this.maxBrightness, required this.minContrast, }); } /// Analyses [file] for blur / brightness / contrast problems. /// /// Safe to call on large photos: the image is downscaled using the platform's /// native decoder, and the (pure Dart, CPU-heavy) analysis runs in a background /// isolate so the UI thread never blocks. /// /// Never throws — on any failure it returns [ImageQualityCheckResult.skipped]. Future analyzeImageQuality( File file, { QualityConfig config = const QualityConfig(), }) async { try { final Uint8List originalBytes = await file.readAsBytes(); // Downscale natively first. This keeps peak memory low and is far faster // than decoding at full resolution in Dart. final Uint8List? preview = await _downscaleToPng(originalBytes); if (preview == null) return ImageQualityCheckResult.skipped; return await compute( _analyzeInIsolate, _AnalysisRequest( pngBytes: preview, blurThreshold: config.blurThreshold, minBrightness: config.minBrightness, maxBrightness: config.maxBrightness, minContrast: config.minContrast, ), ); } catch (e, s) { log('Image quality analysis failed', name: 'ImageQuality', error: e, stackTrace: s); return ImageQualityCheckResult.skipped; } } /// Decodes [bytes] at a reduced resolution using the engine's native codec and /// re-encodes the small result as PNG. /// /// PNG is used because it is lossless (so brightness/contrast statistics are /// preserved exactly) and because `ui.Image.toByteData` can produce it without /// pulling in a Dart encoder. Future _downscaleToPng(Uint8List bytes) async { ui.ImmutableBuffer? buffer; ui.ImageDescriptor? descriptor; ui.Codec? codec; ui.Image? image; try { buffer = await ui.ImmutableBuffer.fromUint8List(bytes); descriptor = await ui.ImageDescriptor.encoded(buffer); final int width = descriptor.width; final int height = descriptor.height; if (width <= 0 || height <= 0) return null; final int longestEdge = width >= height ? width : height; // Only ever scale down, never up. final double scale = longestEdge > _kMaxAnalysisDimension ? _kMaxAnalysisDimension / longestEdge : 1.0; final int targetWidth = (width * scale).round().clamp(1, width); final int targetHeight = (height * scale).round().clamp(1, height); codec = await descriptor.instantiateCodec( targetWidth: targetWidth, targetHeight: targetHeight, ); final ui.FrameInfo frame = await codec.getNextFrame(); image = frame.image; final ByteData? png = await image.toByteData(format: ui.ImageByteFormat.png); return png?.buffer.asUint8List(); } catch (e, s) { log('Downscale failed', name: 'ImageQuality', error: e, stackTrace: s); return null; } finally { image?.dispose(); codec?.dispose(); descriptor?.dispose(); buffer?.dispose(); } } /// Runs in a background isolate. Must stay a top-level function. Future _analyzeInIsolate(_AnalysisRequest request) async { try { final validator = ImageQualityValidator( config: QualityConfig( blurThreshold: request.blurThreshold, minBrightness: request.minBrightness, maxBrightness: request.maxBrightness, minContrast: request.minContrast, ), ); final result = await validator.validate(request.pngBytes); return ImageQualityCheckResult( isValid: result.isValid, issues: List.unmodifiable(result.issues), ); } catch (_) { // Decode/analysis problems must not crash the isolate — fail open. return ImageQualityCheckResult.skipped; } }