Skip to content

Commit

Permalink
feat: 3656 - privacy compliance for cropped new images (#3673)
Browse files Browse the repository at this point in the history
* feat: 3656 - privacy compliance for cropped new images

Impacted files:
* `background_task_image.dart`: now we crop here the image before sending it
* `new_crop_page.dart`: now we either let the server crop (old image) or let the background task crop (brand new image)
* `rotated_crop_controller.dart`: refactored adding a static method `getCroppedBitmap`

* feat: 3656 - bug fix - server gallery images should be considered as new images

Impacted files:
* `image_crop_page.dart`: minor refactoring
* `new_crop_page.dart`: minor refactoring
* `product_image_viewer.dart`: minor refactoring
* `uploaded_image_gallery.dart`: bug fix - we should consider that it's a new image
  • Loading branch information
monsieurtanuki authored Feb 5, 2023
1 parent bf1fe91 commit c9935da
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 51 deletions.
122 changes: 103 additions & 19 deletions packages/smooth_app/lib/background/background_task_image.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:image/image.dart' as image2;
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:provider/provider.dart';
import 'package:smooth_app/background/abstract_background_task.dart';
Expand All @@ -12,6 +17,8 @@ import 'package:smooth_app/data_models/up_to_date_changes.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:smooth_app/database/transient_file.dart';
import 'package:smooth_app/query/product_query.dart';
import 'package:smooth_app/tmp_crop_image/rotated_crop_controller.dart';
import 'package:smooth_app/tmp_crop_image/rotation.dart';

/// Background task about product image upload.
class BackgroundTaskImage extends AbstractBackgroundTask {
Expand Down Expand Up @@ -238,6 +245,11 @@ class BackgroundTaskImage extends AbstractBackgroundTask {
} catch (e) {
// not likely, but let's not spoil the task for that either.
}
try {
File(_getCroppedPath()).deleteSync();
} catch (e) {
// possible, but let's not spoil the task for that either.
}
TransientFile.removeImage(
ImageField.fromOffTag(imageField)!,
barcode,
Expand All @@ -252,39 +264,111 @@ class BackgroundTaskImage extends AbstractBackgroundTask {
}
}

/// 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,
);

/// Conversion factor to `int` from / to UI / background task.
static const int cropConversionFactor = 1000000;

/// Returns true if a cropped operation is needed - after having performed it.
Future<bool> _crop(final File file) async {
if (cropX1 == 0 &&
cropY1 == 0 &&
cropX2 == cropConversionFactor &&
cropY2 == cropConversionFactor &&
rotationDegrees == 0) {
// in that case, no need to crop
return false;
}
final ui.Image image = await loadUiImage(
await File(fullPath).readAsBytes(),
);
final image2.Image? rawImage = await RotatedCropController.getCroppedBitmap(
crop: getResizedRect(
Rect.fromLTRB(
cropX1.toDouble(),
cropY1.toDouble(),
cropX2.toDouble(),
cropY2.toDouble(),
),
1 / cropConversionFactor,
),
rotation: RotationExtension.fromDegrees(rotationDegrees)!,
image: image,
maxSize: null,
quality: FilterQuality.high,
);
if (rawImage == null) {
throw Exception('Cannot crop file');
}
final Uint8List data = Uint8List.fromList(image2.encodeJpg(rawImage));
await file.writeAsBytes(data);
return true;
}

/// Returns the path of the locally computed cropped path (if relevant).
String _getCroppedPath() => '$fullPath.cropped.jpg';

/// Uploads the product image.
@override
Future<void> upload() async {
final String path;
final String croppedPath = _getCroppedPath();
if (await _crop(File(croppedPath))) {
path = croppedPath;
} else {
path = fullPath;
}

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(fullPath),
imageUri: Uri.parse(path),
);

final Status status = await OpenFoodAPIClient.addProductImage(user, image);
final int? imageId = status.imageId;
if (imageId == null) {
throw Exception(
'Could not upload picture: ${status.status} / ${status.error}');
if (status.status == 'status ok') {
// successfully uploaded a new picture and set it as field+language
return;
}
final String? imageUrl = await OpenFoodAPIClient.setProductImageCrop(
barcode: barcode,
imageField: imageField,
language: language,
imgid: '$imageId',
angle: ImageAngleExtension.fromInt(rotationDegrees)!,
x1: cropX1,
y1: cropY1,
x2: cropX2,
y2: cropY2,
user: user,
);
if (imageUrl == null) {
throw Exception('Could not select picture');
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,
);
if (imageUrl == null) {
throw Exception('Could not select picture');
}
return;
}
throw Exception(
'Could not upload picture: ${status.status} / ${status.error}');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class UploadedImageGallery extends StatelessWidget {
imageField: imageField,
inputFile: imageFile,
imageId: imageId,
brandNewPicture: false,
initiallyDifferent: true,
),
fullscreenDialog: true,
),
Expand Down
2 changes: 1 addition & 1 deletion packages/smooth_app/lib/pages/image_crop_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Future<File?> confirmAndUploadNewPicture(
barcode: barcode,
imageField: imageField,
inputFile: File(croppedPhoto.path),
brandNewPicture: true,
initiallyDifferent: true,
),
fullscreenDialog: true,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ class _ProductImageViewerState extends State<ProductImageViewer> {
imageField: _imageData.imageField,
inputFile: imageFile,
imageId: imageId,
brandNewPicture: false,
initiallyDifferent: false,
initialCropRect: initialCropRect,
initialRotation: initialRotation,
),
Expand Down
35 changes: 22 additions & 13 deletions packages/smooth_app/lib/tmp_crop_image/new_crop_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class CropPage extends StatefulWidget {
required this.inputFile,
required this.barcode,
required this.imageField,
required this.brandNewPicture,
required this.initiallyDifferent,
this.imageId,
this.initialCropRect,
this.initialRotation,
Expand All @@ -43,8 +43,8 @@ class CropPage extends StatefulWidget {
final ImageField imageField;
final String barcode;

/// Is that a new picture we crop, or an existing picture?
final bool brandNewPicture;
/// Is the full picture initially different from the current selection?
final bool initiallyDifferent;

/// Only makes sense when we deal with an "already existing" image.
final int? imageId;
Expand Down Expand Up @@ -75,15 +75,9 @@ class _CropPageState extends State<CropPage> {
late Rect _initialCrop;
late Rotation _initialRotation;

Future<ui.Image> _loadUiImage(final Uint8List list) async {
final Completer<ui.Image> completer = Completer<ui.Image>();
ui.decodeImageFromList(list, completer.complete);
return completer.future;
}

Future<void> _load(final Uint8List list) async {
setState(() => _processing = true);
_image = await _loadUiImage(list);
_image = await BackgroundTaskImage.loadUiImage(list);
_initialCrop = _getInitialRect();
_initialRotation = widget.initialRotation ?? Rotation.noon;
_controller = RotatedCropController(
Expand Down Expand Up @@ -258,12 +252,17 @@ class _CropPageState extends State<CropPage> {
return true;
}

final Rect cropRect = _getCropRect();
if (widget.imageId == null) {
// in this case, it's a brand new picture, with crop parameters.
// for performance reasons, we do not crop the image full-size here,
// but in the background task.
// for privacy reasons, we won't send the full image to the server and
// let it crop it: we'll send the cropped image directly.
final File fullFile = await _getFullImageFile(
directory,
sequenceNumber,
);
final Rect cropRect = _getLocalCropRect();
await BackgroundTaskImage.addTask(
widget.barcode,
imageField: widget.imageField,
Expand All @@ -277,6 +276,11 @@ class _CropPageState extends State<CropPage> {
widget: this,
);
} else {
// in this case, it's an existing picture, with crop parameters.
// we let the server do everything: better performance, and no privacy
// issue here (we're cropping from an allegedly already privacy compliant
// picture).
final Rect cropRect = _getServerCropRect();
await BackgroundTaskCrop.addTask(
widget.barcode,
imageField: widget.imageField,
Expand Down Expand Up @@ -305,7 +309,12 @@ class _CropPageState extends State<CropPage> {
return true;
}

Rect _getCropRect() {
/// Returns the crop rect according to local cropping method * factor.
Rect _getLocalCropRect() => BackgroundTaskImage.getResizedRect(
_controller.crop, BackgroundTaskImage.cropConversionFactor);

/// Returns the crop rect according to server cropping method.
Rect _getServerCropRect() {
final Offset center = _controller.getRotatedOffsetForOff(
_controller.crop.center,
);
Expand Down Expand Up @@ -337,7 +346,7 @@ class _CropPageState extends State<CropPage> {
Future<bool> _mayExitPage({required final bool saving}) async {
if (_controller.value.rotation == _initialRotation &&
_controller.value.crop == _initialCrop &&
!widget.brandNewPicture) {
!widget.initiallyDifferent) {
// nothing has changed, let's leave
if (saving) {
Navigator.of(context).pop();
Expand Down
52 changes: 36 additions & 16 deletions packages/smooth_app/lib/tmp_crop_image/rotated_crop_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,19 +140,39 @@ class RotatedCropController extends ValueNotifier<RotatedCropControllerValue> {
Future<image2.Image?> croppedBitmap({
final double? maxSize,
final ui.FilterQuality quality = FilterQuality.high,
}) async =>
getCroppedBitmap(
maxSize: maxSize,
quality: quality,
crop: crop,
rotation: value.rotation,
image: _bitmap,
);

/// Returns the bitmap cropped with parameters.
///
/// [maxSize] is the maximum width or height you want.
/// The [crop] `Rect` is normalized to (0, 0) x (1, 1).
/// You can provide the [quality] used in the resizing operation.
static Future<image2.Image?> getCroppedBitmap({
final double? maxSize,
final ui.FilterQuality quality = FilterQuality.high,
required final Rect crop,
required final Rotation rotation,
required final ui.Image image,
}) async {
final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
final Canvas canvas = Canvas(pictureRecorder);

final bool tilted = value.rotation.isTilted;
final bool tilted = rotation.isTilted;
final double cropWidth;
final double cropHeight;
if (tilted) {
cropWidth = crop.width * _bitmapSize.height;
cropHeight = crop.height * _bitmapSize.width;
cropWidth = crop.width * image.height;
cropHeight = crop.height * image.width;
} else {
cropWidth = crop.width * _bitmapSize.width;
cropHeight = crop.height * _bitmapSize.height;
cropWidth = crop.width * image.width;
cropHeight = crop.height * image.height;
}
// factor between the full size and the maxSize constraint.
double factor = 1;
Expand All @@ -174,38 +194,38 @@ class RotatedCropController extends ValueNotifier<RotatedCropControllerValue> {
..style = PaintingStyle.fill,
);

final Offset cropCenter = value.rotation.getRotatedOffset(
value.crop.center,
_bitmapSize.width,
_bitmapSize.height,
final Offset cropCenter = rotation.getRotatedOffset(
crop.center,
image.width.toDouble(),
image.height.toDouble(),
);

final double alternateWidth = tilted ? cropHeight : cropWidth;
final double alternateHeight = tilted ? cropWidth : cropHeight;
if (value.rotation != Rotation.noon) {
if (rotation != Rotation.noon) {
canvas.save();
final double x = alternateWidth / 2 * factor;
final double y = alternateHeight / 2 * factor;
canvas.translate(x, y);
canvas.rotate(value.rotation.radians);
if (value.rotation == Rotation.threeOClock) {
canvas.rotate(rotation.radians);
if (rotation == Rotation.threeOClock) {
// TODO(monsieurtanuki): put in class Rotation?
canvas.translate(
-y,
-cropWidth * factor + x,
);
} else if (value.rotation == Rotation.nineOClock) {
} else if (rotation == Rotation.nineOClock) {
canvas.translate(
y - cropHeight * factor,
-x,
);
} else if (value.rotation == Rotation.sixOClock) {
} else if (rotation == Rotation.sixOClock) {
canvas.translate(-x, -y);
}
}

canvas.drawImageRect(
_bitmap,
image,
Rect.fromCenter(
center: cropCenter,
width: alternateWidth,
Expand All @@ -220,7 +240,7 @@ class RotatedCropController extends ValueNotifier<RotatedCropControllerValue> {
Paint()..filterQuality = quality,
);

if (value.rotation != Rotation.noon) {
if (rotation != Rotation.noon) {
canvas.restore();
}

Expand Down

0 comments on commit c9935da

Please sign in to comment.