bug 2319, 2284 fixed.

main_production_copilot
Sikander Saleem 5 hours ago
parent 22cf75fce7
commit 166f43979a

@ -0,0 +1,166 @@
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<double>` 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<String> issues;
final bool analysisFailed;
const ImageQualityCheckResult({
required this.isValid,
this.issues = const <String>[],
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<ImageQualityCheckResult> 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<Uint8List?> _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<ImageQualityCheckResult> _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<String>.unmodifiable(result.issues),
);
} catch (_) {
// Decode/analysis problems must not crash the isolate — fail open.
return ImageQualityCheckResult.skipped;
}
}

@ -120,7 +120,7 @@ class CopilotProvider extends ChangeNotifier {
"query": query,
},
);
await _consumeStream(streamedResponse, assistantMsg);
await _consumeStream(streamedResponse, assistantMsg, null);
} catch (e) {
_onStreamError(assistantMsg, e);
}
@ -129,8 +129,8 @@ class CopilotProvider extends ChangeNotifier {
Future<void> sendAudio(File audioFile) async {
if (isStreaming) return;
sessionId ??= _generateSessionId();
messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now()));
final humanAudioMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now());
messages.add(humanAudioMsg);
final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now());
messages.add(assistantMsg);
isStreaming = true;
@ -148,7 +148,7 @@ class CopilotProvider extends ChangeNotifier {
file: audioFile,
filename: audioFile.path.split('/').last,
);
await _consumeStream(streamedResponse, assistantMsg);
await _consumeStream(streamedResponse, assistantMsg, humanAudioMsg);
} catch (e) {
_onStreamError(assistantMsg, e);
}
@ -164,7 +164,7 @@ class CopilotProvider extends ChangeNotifier {
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}';
}
Future<void> _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg) async {
Future<void> _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) async {
if (streamedResponse.statusCode < 200 || streamedResponse.statusCode >= 300) {
_onStreamError(assistantMsg, "Server error ${streamedResponse.statusCode}");
return;
@ -178,12 +178,12 @@ class CopilotProvider extends ChangeNotifier {
final idx = buffer.indexOf('\n\n');
final rawEvent = buffer.substring(0, idx);
buffer = buffer.substring(idx + 2);
_handleSseEvent(rawEvent, assistantMsg);
_handleSseEvent(rawEvent, assistantMsg, humanAudioMsg);
}
},
onDone: () {
if (buffer.trim().isNotEmpty) {
_handleSseEvent(buffer, assistantMsg);
_handleSseEvent(buffer, assistantMsg, humanAudioMsg);
}
assistantMsg.isStreaming = false;
isStreaming = false;
@ -194,7 +194,7 @@ class CopilotProvider extends ChangeNotifier {
);
}
void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg) {
void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) {
final dataLines = rawEvent.split('\n').where((line) => line.startsWith('data:')).map((line) => line.substring(5).trim()).toList();
if (dataLines.isEmpty) return;
@ -219,6 +219,10 @@ class CopilotProvider extends ChangeNotifier {
_onStreamError(assistantMsg, event.content?.answer ?? 'Something went wrong');
return;
}
if (event.type == 'transcription' && event.transcriptionContent != null) {
humanAudioMsg?.content = event.transcriptionContent ?? '';
humanAudioMsg?.localAudioPath = null;
}
notifyListeners();
} catch (_) {}
}

@ -84,12 +84,14 @@ class CopilotMessage {
class CopilotStreamEvent {
final String type;
final CopilotStreamContent? content;
final String? transcriptionContent;
CopilotStreamEvent({required this.type, this.content});
CopilotStreamEvent({required this.type, this.content, this.transcriptionContent});
factory CopilotStreamEvent.fromJson(Map<String, dynamic> json) => CopilotStreamEvent(
type: json['type'] ?? '',
content: json['content'] is Map<String, dynamic> ? CopilotStreamContent.fromJson(json['content']) : null,
transcriptionContent: json['content'] is String ? json['content'] : null,
);
}

@ -2,19 +2,19 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:image_blur_detection/image_blur_detection.dart';
import 'package:image_picker/image_picker.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/helper/utils.dart';
import 'package:test_sa/helper/image_quality_helper.dart';
import 'package:test_sa/models/generic_attachment_model.dart';
import 'package:test_sa/models/lookup.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import '../../../new_views/common_widgets/app_dashed_button.dart';
import '../../../new_views/common_widgets/app_lazy_loading.dart';
import 'multi_image_picker_item.dart';
class AttachmentModel {
@ -178,6 +178,7 @@ class _AttachmentPickerState extends State<AttachmentPicker> with SingleTickerPr
if (widget.onChange != null) {
widget.onChange!(widget.attachment);
}
if (!mounted) return;
setState(() {});
}
}
@ -251,8 +252,11 @@ class _AttachmentPickerState extends State<AttachmentPicker> with SingleTickerPr
icon: 'file_icon',
label: '${context.translation.open}\n${context.translation.files}',
onTap: () async {
// Capture before the await so the sheet is dismissed safely even
// if this element's context is gone by the time we resume.
final NavigatorState sheetNavigator = Navigator.of(context);
await fromFilePicker();
Navigator.pop(context);
if (sheetNavigator.canPop()) sheetNavigator.pop();
},
),
];
@ -317,11 +321,12 @@ class _AttachmentPickerState extends State<AttachmentPicker> with SingleTickerPr
if (widget.pickMultiple && source == ImageSource.gallery) {
final pickedFiles = await ImagePicker().pickMultiImage(imageQuality: 70);
if (!mounted) return;
if (pickedFiles.isNotEmpty) {
pickedFiles.forEach((pickedFile) {
for (final pickedFile in pickedFiles) {
File fileImage = File(pickedFile.path);
widget.attachment.add(GenericAttachmentModel(id: 0, name: fileImage.path, documentTypeId: widget.documentType));
});
}
if (widget.onChange != null) {
widget.onChange!(widget.attachment);
}
@ -331,22 +336,46 @@ class _AttachmentPickerState extends State<AttachmentPicker> with SingleTickerPr
}
final pickedFile = await ImagePicker().pickImage(source: source, imageQuality: 50);
if (!mounted) return;
if (pickedFile != null) {
File fileImage = File(pickedFile.path);
if (checkQuality) {
ImageQualityValidator imageQualityValidator = ImageQualityValidator(config: const QualityConfig(blurThreshold: 70.0));
final result = await imageQualityValidator.validate(await fileImage.readAsBytes());
// NOTE: deliberately not using Utils.showLoading here — it defers the
// showDialog into addPostFrameCallback, which can race with a fast
// dismiss and leave the loader stuck on screen. showDialog pushes its
// route synchronously, so the matching pop below is always safe.
final NavigatorState navigator = Navigator.of(context);
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (_) => const AppLazyLoading(),
);
// Runs on a downscaled copy in a background isolate; never throws.
final result = await analyzeImageQuality(
fileImage,
config: const QualityConfig(blurThreshold: 25.0),
);
if (!mounted) return;
if (navigator.canPop()) navigator.pop();
if (!result.isValid) {
String issues = "Your image have following problems:";
result.issues.forEach((issue) => (issues += "\n- $issue"));
for (final issue in result.issues) {
issues += "\n- $issue";
}
context.showInfoDialog(issues, title: "Bad Quality Found");
return;
}
}
if (!mounted) return;
widget.attachment.add(GenericAttachmentModel(id: 0, name: fileImage.path, documentTypeId: widget.documentType));
if (widget.onChange != null) {
widget.onChange!(widget.attachment);

Loading…
Cancel
Save