mirror of
https://github.com/openfoodfacts/smooth-app.git
synced 2025-08-06 18:25:11 +08:00
427 lines
13 KiB
Dart
427 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:crop_image/crop_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:openfoodfacts/openfoodfacts.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:smooth_app/background/background_task_barcode.dart';
|
|
import 'package:smooth_app/background/background_task_price.dart';
|
|
import 'package:smooth_app/background/background_task_queue.dart';
|
|
import 'package:smooth_app/background/background_task_refresh_later.dart';
|
|
import 'package:smooth_app/background/background_task_upload.dart';
|
|
import 'package:smooth_app/background/operation_type.dart';
|
|
import 'package:smooth_app/data_models/up_to_date_changes.dart';
|
|
import 'package:smooth_app/database/local_database.dart';
|
|
import 'package:smooth_app/helpers/image_compute_container.dart';
|
|
import 'package:smooth_app/l10n/app_localizations.dart';
|
|
import 'package:smooth_app/pages/crop_helper.dart';
|
|
import 'package:smooth_app/pages/prices/eraser_model.dart';
|
|
import 'package:smooth_app/pages/prices/eraser_painter.dart';
|
|
|
|
/// Background task about product image upload.
|
|
class BackgroundTaskImage extends BackgroundTaskUpload {
|
|
BackgroundTaskImage._({
|
|
required super.processName,
|
|
required super.uniqueId,
|
|
required super.barcode,
|
|
required super.productType,
|
|
required super.language,
|
|
required super.stamp,
|
|
required super.imageField,
|
|
required super.croppedPath,
|
|
required super.rotationDegrees,
|
|
required super.cropX1,
|
|
required super.cropY1,
|
|
required super.cropX2,
|
|
required super.cropY2,
|
|
required this.fullPath,
|
|
required this.eraserCoordinates,
|
|
});
|
|
|
|
BackgroundTaskImage.fromJson(super.json)
|
|
: fullPath = json[_jsonTagImagePath] as String,
|
|
eraserCoordinates = BackgroundTaskPrice.fromJsonListDouble(
|
|
json[_jsonTagEraserCoordinates],
|
|
),
|
|
super.fromJson();
|
|
|
|
static const String _jsonTagImagePath = 'imagePath';
|
|
static const String _jsonTagEraserCoordinates = 'eraserCoordinates';
|
|
|
|
static const OperationType _operationType = OperationType.image;
|
|
|
|
final String fullPath;
|
|
final List<double>? eraserCoordinates;
|
|
|
|
@override
|
|
Map<String, dynamic> toJson() {
|
|
final Map<String, dynamic> result = super.toJson();
|
|
result[_jsonTagImagePath] = fullPath;
|
|
result[_jsonTagEraserCoordinates] = eraserCoordinates;
|
|
return result;
|
|
}
|
|
|
|
// cf. https://github.com/openfoodfacts/smooth-app/issues/4219
|
|
static bool isPictureBigEnough(final num width, final num height) =>
|
|
width >= ImageHelper.minimumWidth || height >= ImageHelper.minimumHeight;
|
|
|
|
/// Adds the background task about uploading a product image.
|
|
static Future<void> addTask(
|
|
final String barcode, {
|
|
required final ProductType? productType,
|
|
required final OpenFoodFactsLanguage language,
|
|
required final ImageField imageField,
|
|
required final File fullFile,
|
|
required final File croppedFile,
|
|
required final int rotation,
|
|
required final int x1,
|
|
required final int y1,
|
|
required final int x2,
|
|
required final int y2,
|
|
required final List<double> eraserCoordinates,
|
|
required final BuildContext context,
|
|
}) async {
|
|
final LocalDatabase localDatabase = context.read<LocalDatabase>();
|
|
final String uniqueId = await _operationType.getNewKey(
|
|
localDatabase,
|
|
barcode: barcode,
|
|
);
|
|
final BackgroundTaskBarcode task = _getNewTask(
|
|
language,
|
|
barcode,
|
|
productType ?? ProductType.food,
|
|
imageField,
|
|
fullFile,
|
|
croppedFile,
|
|
uniqueId,
|
|
rotation,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
eraserCoordinates,
|
|
);
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
await task.addToManager(
|
|
localDatabase,
|
|
context: context,
|
|
queue: BackgroundTaskQueue.slow,
|
|
);
|
|
}
|
|
|
|
@override
|
|
(String, AlignmentGeometry)? getFloatingMessage(
|
|
final AppLocalizations appLocalizations,
|
|
) => null;
|
|
|
|
/// Returns a new background task about changing a product.
|
|
static BackgroundTaskImage _getNewTask(
|
|
final OpenFoodFactsLanguage language,
|
|
final String barcode,
|
|
final ProductType productType,
|
|
final ImageField imageField,
|
|
final File fullFile,
|
|
final File croppedFile,
|
|
final String uniqueId,
|
|
final int rotationDegrees,
|
|
final int cropX1,
|
|
final int cropY1,
|
|
final int cropX2,
|
|
final int cropY2,
|
|
final List<double>? eraserCoordinates,
|
|
) => BackgroundTaskImage._(
|
|
uniqueId: uniqueId,
|
|
barcode: barcode,
|
|
productType: productType,
|
|
processName: _operationType.processName,
|
|
imageField: imageField.offTag,
|
|
fullPath: fullFile.path,
|
|
croppedPath: croppedFile.path,
|
|
rotationDegrees: rotationDegrees,
|
|
cropX1: cropX1,
|
|
cropY1: cropY1,
|
|
cropX2: cropX2,
|
|
cropY2: cropY2,
|
|
eraserCoordinates: eraserCoordinates,
|
|
language: language,
|
|
stamp: BackgroundTaskUpload.getStamp(
|
|
barcode,
|
|
imageField.offTag,
|
|
language.code,
|
|
),
|
|
);
|
|
|
|
/// Returns a fake value that means: "remove the previous value when merging".
|
|
///
|
|
/// If we use this task, it means that we took a brand new picture. Therefore,
|
|
/// all previous crop parameters are attached to a different imageid, and
|
|
/// to avoid confusion we need to clear them.
|
|
/// cf. [UpToDateChanges._overwrite] regarding `images` field.
|
|
@override
|
|
ProductImage getProductImageChange() => ProductImage(
|
|
field: ImageField.fromOffTag(imageField)!,
|
|
language: getLanguage(),
|
|
size: ImageSize.ORIGINAL,
|
|
);
|
|
|
|
// TODO(monsieurtanuki): we may also need to remove old files that were not removed from some reason
|
|
@override
|
|
Future<void> postExecute(
|
|
final LocalDatabase localDatabase,
|
|
final bool success,
|
|
) async {
|
|
await super.postExecute(localDatabase, success);
|
|
try {
|
|
(await BackgroundTaskUpload.getFile(fullPath)).deleteSync();
|
|
} catch (e) {
|
|
// not likely, but let's not spoil the task for that either.
|
|
}
|
|
try {
|
|
(await BackgroundTaskUpload.getFile(croppedPath)).deleteSync();
|
|
} catch (e) {
|
|
// not likely, but let's not spoil the task for that either.
|
|
}
|
|
try {
|
|
(await BackgroundTaskUpload.getFile(
|
|
await getCroppedPath(fullPath),
|
|
)).deleteSync();
|
|
} catch (e) {
|
|
// possible, but let's not spoil the task for that either.
|
|
}
|
|
removeTransientImage(localDatabase);
|
|
if (success) {
|
|
await BackgroundTaskRefreshLater.addTask(
|
|
barcode,
|
|
localDatabase: localDatabase,
|
|
productType: productType,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns an image loaded from data.
|
|
static Future<ui.Image> loadUiImage(final Uint8List list) async {
|
|
final Completer<ui.Image> completer = Completer<ui.Image>();
|
|
ui.decodeImageFromList(list, completer.complete);
|
|
return completer.future;
|
|
}
|
|
|
|
/// Returns [source] with all corners multiplied by a [factor].
|
|
static Rect getResizedRect(final Rect source, final num factor) =>
|
|
Rect.fromLTRB(
|
|
source.left * factor,
|
|
source.top * factor,
|
|
source.right * factor,
|
|
source.bottom * factor,
|
|
);
|
|
|
|
static Rect getUpsizedRect(final Rect source) =>
|
|
getResizedRect(source, _cropConversionFactor);
|
|
|
|
static Rect getDownsizedRect(
|
|
final int cropX1,
|
|
final int cropY1,
|
|
final int cropX2,
|
|
final int cropY2,
|
|
) => getResizedRect(
|
|
Rect.fromLTRB(
|
|
cropX1.toDouble(),
|
|
cropY1.toDouble(),
|
|
cropX2.toDouble(),
|
|
cropY2.toDouble(),
|
|
),
|
|
1 / _cropConversionFactor,
|
|
);
|
|
|
|
/// Conversion factor to `int` from / to UI / background task.
|
|
static const int _cropConversionFactor = 1000000;
|
|
|
|
/// Returns the file path of a crop operation.
|
|
///
|
|
/// Returns directly the original [fullPath] if no crop operation was needed.
|
|
/// Returns the path of the cropped file if relevant.
|
|
/// Returns null if the image (cropped or not) is too small.
|
|
static Future<String?> cropIfNeeded({
|
|
required final String fullPath,
|
|
required final int rotationDegrees,
|
|
required final int cropX1,
|
|
required final int cropY1,
|
|
required final int cropX2,
|
|
required final int cropY2,
|
|
required final int compressQuality,
|
|
required final bool forceCompression,
|
|
required final List<double>? eraserCoordinates,
|
|
}) async {
|
|
final String croppedPath = await getCroppedPath(fullPath);
|
|
|
|
final CustomPainter? overlayPainter =
|
|
eraserCoordinates == null || eraserCoordinates.isEmpty
|
|
? null
|
|
: EraserPainter(
|
|
eraserModel: EraserModel(
|
|
rotation: CropRotationExtension.fromDegrees(rotationDegrees)!,
|
|
offsets: CropHelper.getOffsets(eraserCoordinates),
|
|
),
|
|
cropRect: BackgroundTaskImage.getDownsizedRect(
|
|
cropX1,
|
|
cropY1,
|
|
cropX2,
|
|
cropY2,
|
|
),
|
|
);
|
|
final ui.Image full = await loadUiImage(
|
|
await (await BackgroundTaskUpload.getFile(fullPath)).readAsBytes(),
|
|
);
|
|
if (!forceCompression) {
|
|
if (cropX1 == 0 &&
|
|
cropY1 == 0 &&
|
|
cropX2 == _cropConversionFactor &&
|
|
cropY2 == _cropConversionFactor &&
|
|
rotationDegrees == 0) {
|
|
if (!isPictureBigEnough(full.width, full.height)) {
|
|
return null;
|
|
}
|
|
// in that case, no need to crop
|
|
if (overlayPainter == null) {
|
|
return fullPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
Size getCroppedSize() {
|
|
final Rect cropRect = getDownsizedRect(cropX1, cropY1, cropX2, cropY2);
|
|
switch (CropRotationExtension.fromDegrees(rotationDegrees)!) {
|
|
case CropRotation.up:
|
|
case CropRotation.down:
|
|
return Size(
|
|
cropRect.width * full.height,
|
|
cropRect.height * full.width,
|
|
);
|
|
case CropRotation.left:
|
|
case CropRotation.right:
|
|
return Size(
|
|
cropRect.width * full.width,
|
|
cropRect.height * full.height,
|
|
);
|
|
}
|
|
}
|
|
|
|
final Size croppedSize = getCroppedSize();
|
|
if (!isPictureBigEnough(croppedSize.width, croppedSize.height)) {
|
|
return null;
|
|
}
|
|
final ui.Image cropped = await CropController.getCroppedBitmap(
|
|
crop: getDownsizedRect(cropX1, cropY1, cropX2, cropY2),
|
|
rotation: CropRotationExtension.fromDegrees(rotationDegrees)!,
|
|
image: full,
|
|
maxSize: null,
|
|
quality: FilterQuality.high,
|
|
overlayPainter: overlayPainter,
|
|
);
|
|
await saveJpeg(
|
|
file: await BackgroundTaskUpload.getFile(croppedPath),
|
|
source: cropped,
|
|
quality: compressQuality,
|
|
);
|
|
return croppedPath;
|
|
}
|
|
|
|
static Future<String> getCroppedPath(final String fullPath) async {
|
|
final String croppedPath = '$fullPath.cropped.jpg';
|
|
|
|
if (_isFileWritable(File(croppedPath))) {
|
|
return croppedPath;
|
|
}
|
|
|
|
// In some cases, the location of the directory from
|
|
// [BackgroundTaskUpload.getDirectory()] (= "application support" dir)
|
|
// may have changed
|
|
//
|
|
// This issue is mainly on iOS devices, when a support directory can become
|
|
// not writable anymore, but is still readable.
|
|
return join(
|
|
(await BackgroundTaskUpload.getDirectory()).path,
|
|
Uri.file(croppedPath).pathSegments.last,
|
|
);
|
|
}
|
|
|
|
/// Uploads the product image.
|
|
@override
|
|
Future<void> upload() async {
|
|
final String? path = await cropIfNeeded(
|
|
fullPath: fullPath,
|
|
rotationDegrees: rotationDegrees,
|
|
cropX1: cropX1,
|
|
cropY1: cropY1,
|
|
cropX2: cropX2,
|
|
cropY2: cropY2,
|
|
compressQuality: 100,
|
|
forceCompression: false,
|
|
eraserCoordinates: eraserCoordinates,
|
|
);
|
|
if (path == null) {
|
|
// TODO(monsieurtanuki): maybe something more refined when we dismiss the picture, like alerting the user, though it's not supposed to happen anymore from upstream.
|
|
return;
|
|
}
|
|
final ImageField imageField = ImageField.fromOffTag(this.imageField)!;
|
|
final OpenFoodFactsLanguage language = getLanguage();
|
|
final User user = getUser();
|
|
final SendImage image = SendImage(
|
|
lang: language,
|
|
barcode: barcode,
|
|
imageField: imageField,
|
|
imageUri: Uri.parse(path),
|
|
);
|
|
|
|
final Status status = await OpenFoodAPIClient.addProductImage(
|
|
user,
|
|
image,
|
|
uriHelper: uriProductHelper,
|
|
);
|
|
if (status.status == 'status ok') {
|
|
// successfully uploaded a new picture and set it as field+language
|
|
return;
|
|
}
|
|
final int? imageId = status.imageId;
|
|
if (status.status == 'status not ok' && imageId != null) {
|
|
// The very same image was already uploaded and therefore was rejected.
|
|
// We just have to select this image, with no angle.
|
|
final String? imageUrl = await OpenFoodAPIClient.setProductImageAngle(
|
|
barcode: barcode,
|
|
imageField: imageField,
|
|
language: language,
|
|
imgid: '$imageId',
|
|
angle: ImageAngle.NOON,
|
|
user: user,
|
|
uriHelper: uriProductHelper,
|
|
);
|
|
if (imageUrl == null) {
|
|
throw Exception('Could not select picture');
|
|
}
|
|
return;
|
|
}
|
|
throw Exception(
|
|
'Could not upload picture: ${status.status} / ${status.error}',
|
|
);
|
|
}
|
|
|
|
static bool _isFileWritable(File file) {
|
|
try {
|
|
final FileStat stat = file.statSync();
|
|
|
|
final bool isOwnerWritable = (stat.mode & 0x0080) != 0;
|
|
final bool isGroupWritable = (stat.mode & 0x0010) != 0;
|
|
final bool isOtherWritable = (stat.mode & 0x0002) != 0;
|
|
|
|
return isOwnerWritable || isGroupWritable || isOtherWritable;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|