From 088dade67fcfe4c94c2c67294a3782eaa27671c0 Mon Sep 17 00:00:00 2001 From: Rover track Date: Sat, 30 May 2026 19:58:30 +0530 Subject: [PATCH 1/3] demo module --- .idea/appInsightsSettings.xml | 26 + .idea/compiler.xml | 2 +- .idea/deploymentTargetSelector.xml | 3 + .idea/deviceManager.xml | 13 + .idea/gradle.xml | 3 + .idea/misc.xml | 8 +- .idea/vcs.xml | 2 +- commons-jpegtran-demo/.gitignore | 1 + commons-jpegtran-demo/build.gradle | 38 ++ commons-jpegtran-demo/proguard-rules.pro | 21 + .../ExampleInstrumentedTest.java | 26 + .../src/main/AndroidManifest.xml | 22 + .../BlurOverlayView.java | 354 +++++++++++++ .../CropOverlayView.java | 395 ++++++++++++++ .../commons_jpegtran_demo/MainActivity.java | 487 ++++++++++++++++++ .../drawable-v24/ic_launcher_foreground.xml | 30 ++ .../res/drawable/ic_launcher_background.xml | 170 ++++++ .../src/main/res/layout/activity_main.xml | 115 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../src/main/res/values-night/themes.xml | 16 + .../src/main/res/values/colors.xml | 10 + .../src/main/res/values/strings.xml | 16 + .../src/main/res/values/themes.xml | 16 + .../ExampleUnitTest.java | 17 + 35 files changed, 1794 insertions(+), 9 deletions(-) create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/deviceManager.xml create mode 100644 commons-jpegtran-demo/.gitignore create mode 100644 commons-jpegtran-demo/build.gradle create mode 100644 commons-jpegtran-demo/proguard-rules.pro create mode 100644 commons-jpegtran-demo/src/androidTest/java/fr/free/nrw/commons/commons_jpegtran_demo/ExampleInstrumentedTest.java create mode 100644 commons-jpegtran-demo/src/main/AndroidManifest.xml create mode 100644 commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/BlurOverlayView.java create mode 100644 commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/CropOverlayView.java create mode 100644 commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/MainActivity.java create mode 100644 commons-jpegtran-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 commons-jpegtran-demo/src/main/res/drawable/ic_launcher_background.xml create mode 100644 commons-jpegtran-demo/src/main/res/layout/activity_main.xml create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 commons-jpegtran-demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 commons-jpegtran-demo/src/main/res/values-night/themes.xml create mode 100644 commons-jpegtran-demo/src/main/res/values/colors.xml create mode 100644 commons-jpegtran-demo/src/main/res/values/strings.xml create mode 100644 commons-jpegtran-demo/src/main/res/values/themes.xml create mode 100644 commons-jpegtran-demo/src/test/java/fr/free/nrw/commons/commons_jpegtran_demo/ExampleUnitTest.java diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b86273d..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..e207374 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -2,6 +2,9 @@ + + diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 97f0a8e..f179173 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ + diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..04b9d02 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,10 +1,4 @@ - - - - - - + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..94a25f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/commons-jpegtran-demo/.gitignore b/commons-jpegtran-demo/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/commons-jpegtran-demo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/commons-jpegtran-demo/build.gradle b/commons-jpegtran-demo/build.gradle new file mode 100644 index 0000000..8937adb --- /dev/null +++ b/commons-jpegtran-demo/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'fr.free.nrw.commons.commons_jpegtran_demo' + compileSdk 35 + + defaultConfig { + applicationId "fr.free.nrw.commons.commons_jpegtran_demo" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'com.google.android.material:material:1.14.0' + testImplementation 'junit:junit:4.13.2' + implementation project(':jpegtran') + androidTestImplementation 'androidx.test.ext:junit:1.3.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' +} \ No newline at end of file diff --git a/commons-jpegtran-demo/proguard-rules.pro b/commons-jpegtran-demo/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/commons-jpegtran-demo/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/commons-jpegtran-demo/src/androidTest/java/fr/free/nrw/commons/commons_jpegtran_demo/ExampleInstrumentedTest.java b/commons-jpegtran-demo/src/androidTest/java/fr/free/nrw/commons/commons_jpegtran_demo/ExampleInstrumentedTest.java new file mode 100644 index 0000000..d641ac2 --- /dev/null +++ b/commons-jpegtran-demo/src/androidTest/java/fr/free/nrw/commons/commons_jpegtran_demo/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.commons_jpegtran_demo; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("fr.free.nrw.commons.commons_jpegtran_demo", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/commons-jpegtran-demo/src/main/AndroidManifest.xml b/commons-jpegtran-demo/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b79f42a --- /dev/null +++ b/commons-jpegtran-demo/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/BlurOverlayView.java b/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/BlurOverlayView.java new file mode 100644 index 0000000..87fd258 --- /dev/null +++ b/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/BlurOverlayView.java @@ -0,0 +1,354 @@ +package fr.free.nrw.commons.commons_jpegtran_demo; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; +import android.widget.ImageView; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import fr.free.nrw.commons.jpegtran.BlurRegion; + +/** + * Custom overlay view to allow users to draw and select multiple rectangular + * regions on top of an image. Supports pinch-to-zoom (two fingers) and + * double-tap to reset while single-finger drag draws blur rectangles. + */ +public class BlurOverlayView extends View { + + private final List regions = new ArrayList<>(); + private RectF currentActiveBox = null; + private float startX, startY; + + private Paint borderPaint; + private Paint fillPaint; + + private final Matrix viewMatrix = new Matrix(); + private final Matrix inverseMatrix = new Matrix(); + private float scaleFactor = 1.0f; + private boolean isScaling = false; + private float lastMidX, lastMidY; + + private static final float DELETE_MARKER_RADIUS_DP = 7f; // Radius of the red background circle + private static final float DELETE_MARKER_X_SIZE_DP = 2f; // Half-length of the cross lines inside the 'X' + private static final float DELETE_MARKER_STROKE_WIDTH_DP = 1f; // Stroke thickness of the 'X' lines + private static final float DELETE_MARKER_TOUCH_RADIUS_DP = 10f; // Radius of the interactive touch target area + + private float density; + private Paint deleteCirclePaint; + private Paint deleteXPaint; + private int deleteBoxIndex = -1; + private float startScreenX, startScreenY; + + private ScaleGestureDetector scaleDetector; + private GestureDetector doubleTapDetector; + + private ImageView imageView; + private OnZoomChangedListener zoomListener; + + /** + * Callback so MainActivity can sync the ImageView matrix. + */ + public interface OnZoomChangedListener { + void onZoomChanged(Matrix viewMatrix); + } + + public void setImageView(ImageView imageView) { + this.imageView = imageView; + } + + public void setOnZoomChangedListener(OnZoomChangedListener l) { + this.zoomListener = l; + } + + public BlurOverlayView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(Context context) { + density = context.getResources().getDisplayMetrics().density; + + borderPaint = new Paint(); + borderPaint.setColor(Color.CYAN); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(4.0f); + borderPaint.setAntiAlias(true); + + fillPaint = new Paint(); + fillPaint.setColor(Color.argb(80, 0, 255, 255)); + fillPaint.setStyle(Paint.Style.FILL); + + deleteCirclePaint = new Paint(); + deleteCirclePaint.setColor(Color.parseColor("#E53935")); // Material Red + deleteCirclePaint.setStyle(Paint.Style.FILL); + deleteCirclePaint.setAntiAlias(true); + + deleteXPaint = new Paint(); + deleteXPaint.setColor(Color.WHITE); + deleteXPaint.setStyle(Paint.Style.STROKE); + deleteXPaint.setStrokeWidth(DELETE_MARKER_STROKE_WIDTH_DP * density); + deleteXPaint.setStrokeCap(Paint.Cap.ROUND); + deleteXPaint.setAntiAlias(true); + + scaleDetector = new ScaleGestureDetector(context, + new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector d) { + float factor = d.getScaleFactor(); + float newScale = Math.max(1.0f, Math.min(5.0f, scaleFactor * factor)); + float actual = newScale / scaleFactor; + viewMatrix.postScale(actual, actual, d.getFocusX(), d.getFocusY()); + scaleFactor = newScale; + constrainPan(); + viewMatrix.invert(inverseMatrix); + if (zoomListener != null) + zoomListener.onZoomChanged(new Matrix(viewMatrix)); + invalidate(); + return true; + } + }); + scaleDetector.setQuickScaleEnabled(false); + + doubleTapDetector = new GestureDetector(context, + new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDoubleTap(MotionEvent e) { + resetZoom(); + return true; + } + }); + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + canvas.save(); + canvas.concat(viewMatrix); + + for (RectF rect : regions) { + canvas.drawRect(rect, fillPaint); + canvas.drawRect(rect, borderPaint); + } + if (currentActiveBox != null) { + canvas.drawRect(currentActiveBox, fillPaint); + canvas.drawRect(currentActiveBox, borderPaint); + } + canvas.restore(); + + // Draw X markers in the top-right corner of each bounding boxes. + float markerRadius = DELETE_MARKER_RADIUS_DP * density; + float markerXSize = DELETE_MARKER_X_SIZE_DP * density; + float[] cornerPt = new float[2]; + + for (RectF rect : regions) { + if (rect.width() > 0 && rect.height() > 0) { + cornerPt[0] = rect.right; + cornerPt[1] = rect.top; + viewMatrix.mapPoints(cornerPt); + float cx = cornerPt[0]; + float cy = cornerPt[1]; + + // Draw background circle + canvas.drawCircle(cx, cy, markerRadius, deleteCirclePaint); + // Draw crossing lines for the X + canvas.drawLine(cx - markerXSize, cy - markerXSize, cx + markerXSize, cy + markerXSize, deleteXPaint); + canvas.drawLine(cx + markerXSize, cy - markerXSize, cx - markerXSize, cy + markerXSize, deleteXPaint); + } + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + scaleDetector.onTouchEvent(event); + doubleTapDetector.onTouchEvent(event); + + // Two-finger gestures - zoom + pan + if (event.getPointerCount() >= 2) { + isScaling = true; + currentActiveBox = null; + deleteBoxIndex = -1; + + float midX = (event.getX(0) + event.getX(1)) / 2f; + float midY = (event.getY(0) + event.getY(1)) / 2f; + + if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { + float dx = midX - lastMidX; + float dy = midY - lastMidY; + viewMatrix.postTranslate(dx, dy); + constrainPan(); + viewMatrix.invert(inverseMatrix); + if (zoomListener != null) zoomListener.onZoomChanged(new Matrix(viewMatrix)); + invalidate(); + } + + lastMidX = midX; + lastMidY = midY; + return true; + } + + if (isScaling) { + if (event.getActionMasked() == MotionEvent.ACTION_UP) isScaling = false; + return true; + } + + // Map screen touch → pre-zoom coordinates + float[] pt = {event.getX(), event.getY()}; + inverseMatrix.mapPoints(pt); + float x = pt[0], y = pt[1]; + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + startX = x; + startY = y; + startScreenX = event.getX(); + startScreenY = event.getY(); + deleteBoxIndex = -1; + + // Check if touch is on any box's 'X' marker (top-right corner) + float touchRadius = DELETE_MARKER_TOUCH_RADIUS_DP * density; + float screenTouchX = event.getX(); + float screenTouchY = event.getY(); + float[] cornerPt = new float[2]; + + for (int i = 0; i < regions.size(); i++) { + RectF rect = regions.get(i); + cornerPt[0] = rect.right; + cornerPt[1] = rect.top; + viewMatrix.mapPoints(cornerPt); + + float dx = screenTouchX - cornerPt[0]; + float dy = screenTouchY - cornerPt[1]; + if (dx * dx + dy * dy <= touchRadius * touchRadius) { + deleteBoxIndex = i; + break; + } + } + return true; + + case MotionEvent.ACTION_MOVE: + if (deleteBoxIndex != -1) { + float dxDrag = event.getX() - startScreenX; + float dyDrag = event.getY() - startScreenY; + // If they drag significantly, cancel delete and start drawing + if (dxDrag * dxDrag + dyDrag * dyDrag > (8f * density) * (8f * density)) { + deleteBoxIndex = -1; + } else { + // Suppress drawing while holding the 'X' marker + return true; + } + } + + if (currentActiveBox == null) { + currentActiveBox = new RectF(startX, startY, startX, startY); + } + currentActiveBox.left = Math.min(startX, x); + currentActiveBox.top = Math.min(startY, y); + currentActiveBox.right = Math.max(startX, x); + currentActiveBox.bottom = Math.max(startY, y); + invalidate(); + return true; + + case MotionEvent.ACTION_UP: + if (deleteBoxIndex != -1) { + if (deleteBoxIndex < regions.size()) { + regions.remove(deleteBoxIndex); + } + deleteBoxIndex = -1; + invalidate(); + return true; + } + + if (currentActiveBox != null) { + if (currentActiveBox.width() > 15 && currentActiveBox.height() > 15) { + regions.add(currentActiveBox); + } + currentActiveBox = null; + invalidate(); + } + return true; + + case MotionEvent.ACTION_CANCEL: + deleteBoxIndex = -1; + currentActiveBox = null; + invalidate(); + return true; + } + return super.onTouchEvent(event); + } + + private void constrainPan() { + float[] v = new float[9]; + viewMatrix.getValues(v); + float s = v[Matrix.MSCALE_X], tx = v[Matrix.MTRANS_X], ty = v[Matrix.MTRANS_Y]; + int w = getWidth(), h = getHeight(); + + float dx = Math.max(w - w * s, Math.min(0, tx)) - tx; + float dy = Math.max(h - h * s, Math.min(0, ty)) - ty; + if (dx != 0 || dy != 0) viewMatrix.postTranslate(dx, dy); + } + + + @NonNull + public List getDrawnRegions() { + return regions; + } + + public void resetZoom() { + scaleFactor = 1.0f; + viewMatrix.reset(); + inverseMatrix.reset(); + if (zoomListener != null) zoomListener.onZoomChanged(new Matrix(viewMatrix)); + invalidate(); + } + + public void clearRegions() { + regions.clear(); + invalidate(); + } + + protected List getMappedBlurRegions(ImageView imageView, BlurOverlayView overlayView) { + List mappedRegions = new ArrayList<>(); + Drawable drawable = imageView.getDrawable(); + if (drawable == null) return mappedRegions; + + int imageWidth = drawable.getIntrinsicWidth(); + int imageHeight = drawable.getIntrinsicHeight(); + + Matrix imageMatrix = imageView.getImageMatrix(); + float[] vals = new float[9]; + imageMatrix.getValues(vals); + float sx = vals[Matrix.MSCALE_X], sy = vals[Matrix.MSCALE_Y]; + float tx = vals[Matrix.MTRANS_X], ty = vals[Matrix.MTRANS_Y]; + + for (RectF r : overlayView.getDrawnRegions()) { + // Map from overlay image-space → screen via viewMatrix, then → pixel coords + RectF screen = new RectF(r); + viewMatrix.mapRect(screen); + + int cornerX = Math.max(0, (int) ((screen.left - tx) / sx)); + int cornerY = Math.max(0, (int) ((screen.top - ty) / sy)); + int w = Math.min(imageWidth - cornerX, (int) ((screen.right - screen.left) / sx)); + int h = Math.min(imageHeight - cornerY, (int) ((screen.bottom - screen.top) / sy)); + + if (w > 0 && h > 0) { + mappedRegions.add(new BlurRegion(w, h, cornerX, cornerY, 100, 100, true)); + } + } + return mappedRegions; + } +} diff --git a/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/CropOverlayView.java b/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/CropOverlayView.java new file mode 100644 index 0000000..dc3216a --- /dev/null +++ b/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/CropOverlayView.java @@ -0,0 +1,395 @@ +package fr.free.nrw.commons.commons_jpegtran_demo; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Custom View that draws a resizable crop rectangle with handles. + * Supports dragging corners/edges to resize and dragging center to move. + */ +public class CropOverlayView extends View { + + private final float density; + private final float handleRadius; + private final float cornerHandleRadius; + private final float minCropSize; + private final float touchSlop; + private final float cornerTouchSlop; + private final float borderWidth; + private final float gridStrokeWidth; + private final float centerIndicatorSize; + + private final RectF cropRect = new RectF(); + private final RectF imageBounds = new RectF(); + + private final Paint borderPaint = new Paint(); + private final Paint handlePaint = new Paint(); + private final Paint activeHandlePaint = new Paint(); + private final Paint handleBorderPaint = new Paint(); + private final Paint overlayPaint = new Paint(); + private final Paint gridPaint = new Paint(); + private final Paint centerIndicatorPaint = new Paint(); + + private Handle activeHandle = null; + private float lastTouchX = 0f; + private float lastTouchY = 0f; + + public enum Handle { + TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, + TOP, BOTTOM, LEFT, RIGHT, CENTER + } + + public CropOverlayView(Context context) { + this(context, null); + } + + public CropOverlayView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CropOverlayView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + density = context.getResources().getDisplayMetrics().density; + handleRadius = 12f * density; + cornerHandleRadius = 16f * density; + minCropSize = 80f * density; + touchSlop = 48f * density; + cornerTouchSlop = 56f * density; + borderWidth = 2f * density; + gridStrokeWidth = 1f * density; + centerIndicatorSize = 16f * density; + + initPaints(); + } + + private void initPaints() { + borderPaint.setColor(Color.WHITE); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(borderWidth); + borderPaint.setAntiAlias(true); + + handlePaint.setColor(Color.WHITE); + handlePaint.setStyle(Paint.Style.FILL); + handlePaint.setAntiAlias(true); + + activeHandlePaint.setColor(Color.parseColor("#2196F3")); // Material Blue + activeHandlePaint.setStyle(Paint.Style.FILL); + activeHandlePaint.setAntiAlias(true); + + handleBorderPaint.setColor(Color.parseColor("#424242")); // Dark gray + handleBorderPaint.setStyle(Paint.Style.STROKE); + handleBorderPaint.setStrokeWidth(1.5f * density); + handleBorderPaint.setAntiAlias(true); + + overlayPaint.setColor(Color.argb(128, 0, 0, 0)); + overlayPaint.setStyle(Paint.Style.FILL); + + gridPaint.setColor(Color.argb(180, 255, 255, 255)); + gridPaint.setStyle(Paint.Style.STROKE); + gridPaint.setStrokeWidth(gridStrokeWidth); + gridPaint.setAntiAlias(true); + + centerIndicatorPaint.setColor(Color.argb(100, 255, 255, 255)); + centerIndicatorPaint.setStyle(Paint.Style.STROKE); + centerIndicatorPaint.setStrokeWidth(1.5f * density); + centerIndicatorPaint.setAntiAlias(true); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (cropRect.isEmpty()) { + resetCropRect(); + } + } + + public void setImageBounds(float left, float top, float right, float bottom) { + imageBounds.set(left, top, right, bottom); + resetCropRect(); + invalidate(); + } + + public void resetCropRect() { + if (!imageBounds.isEmpty()) { + cropRect.set(imageBounds); + } else { + float padding = 50f * density; + cropRect.set( + padding, + padding, + getWidth() - padding, + getHeight() - padding + ); + } + invalidate(); + } + + @NonNull + public RectF getCropRect() { + return new RectF(cropRect); + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + // Draw dark overlay outside crop area + canvas.drawRect(0f, 0f, getWidth(), cropRect.top, overlayPaint); + canvas.drawRect(0f, cropRect.bottom, getWidth(), getHeight(), overlayPaint); + canvas.drawRect(0f, cropRect.top, cropRect.left, cropRect.bottom, overlayPaint); + canvas.drawRect(cropRect.right, cropRect.top, getWidth(), cropRect.bottom, overlayPaint); + + // Draw crop border + canvas.drawRect(cropRect, borderPaint); + + // Draw rule of thirds grid + float thirdWidth = cropRect.width() / 3; + float thirdHeight = cropRect.height() / 3; + for (int i = 1; i <= 2; i++) { + canvas.drawLine( + cropRect.left + thirdWidth * i, cropRect.top, + cropRect.left + thirdWidth * i, cropRect.bottom, + gridPaint + ); + canvas.drawLine( + cropRect.left, cropRect.top + thirdHeight * i, + cropRect.right, cropRect.top + thirdHeight * i, + gridPaint + ); + } + + // Draw center indicator + drawCenterIndicator(canvas); + + // Draw corner handles (larger) + drawCornerHandle(canvas, cropRect.left, cropRect.top, Handle.TOP_LEFT); + drawCornerHandle(canvas, cropRect.right, cropRect.top, Handle.TOP_RIGHT); + drawCornerHandle(canvas, cropRect.left, cropRect.bottom, Handle.BOTTOM_LEFT); + drawCornerHandle(canvas, cropRect.right, cropRect.bottom, Handle.BOTTOM_RIGHT); + + // Draw edge handles (smaller) + drawEdgeHandle(canvas, cropRect.centerX(), cropRect.top, Handle.TOP); + drawEdgeHandle(canvas, cropRect.centerX(), cropRect.bottom, Handle.BOTTOM); + drawEdgeHandle(canvas, cropRect.left, cropRect.centerY(), Handle.LEFT); + drawEdgeHandle(canvas, cropRect.right, cropRect.centerY(), Handle.RIGHT); + + updateGestureExclusion(); + } + + private void drawCornerHandle(Canvas canvas, float x, float y, Handle handle) { + boolean isActive = activeHandle == handle; + Paint paint = isActive ? activeHandlePaint : handlePaint; + + canvas.drawCircle(x, y, cornerHandleRadius, paint); + canvas.drawCircle(x, y, cornerHandleRadius, handleBorderPaint); + } + + private void drawEdgeHandle(Canvas canvas, float x, float y, Handle handle) { + boolean isActive = activeHandle == handle; + Paint paint = isActive ? activeHandlePaint : handlePaint; + + canvas.drawCircle(x, y, handleRadius, paint); + canvas.drawCircle(x, y, handleRadius, handleBorderPaint); + } + + private void drawCenterIndicator(Canvas canvas) { + float centerX = cropRect.centerX(); + float centerY = cropRect.centerY(); + + canvas.drawLine( + centerX - centerIndicatorSize, centerY, + centerX + centerIndicatorSize, centerY, + centerIndicatorPaint + ); + canvas.drawLine( + centerX, centerY - centerIndicatorSize, + centerX, centerY + centerIndicatorSize, + centerIndicatorPaint + ); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + float x = event.getX(); + float y = event.getY(); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + activeHandle = findHandle(x, y); + if (activeHandle != null) { + if (getParent() != null) { + getParent().requestDisallowInterceptTouchEvent(true); + } + lastTouchX = x; + lastTouchY = y; + invalidate(); + return true; + } + break; + case MotionEvent.ACTION_MOVE: + if (activeHandle != null) { + float dx = x - lastTouchX; + float dy = y - lastTouchY; + moveHandle(activeHandle, dx, dy); + lastTouchX = x; + lastTouchY = y; + invalidate(); + return true; + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (activeHandle != null) { + if (getParent() != null) { + getParent().requestDisallowInterceptTouchEvent(false); + } + activeHandle = null; + invalidate(); + } + break; + } + return super.onTouchEvent(event); + } + + @Nullable + private Handle findHandle(float x, float y) { + // Corners first + if (isNear(x, y, cropRect.left, cropRect.top, cornerTouchSlop)) return Handle.TOP_LEFT; + if (isNear(x, y, cropRect.right, cropRect.top, cornerTouchSlop)) return Handle.TOP_RIGHT; + if (isNear(x, y, cropRect.left, cropRect.bottom, cornerTouchSlop)) + return Handle.BOTTOM_LEFT; + if (isNear(x, y, cropRect.right, cropRect.bottom, cornerTouchSlop)) + return Handle.BOTTOM_RIGHT; + + // Edges second + if (isNear(x, y, cropRect.centerX(), cropRect.top, touchSlop)) return Handle.TOP; + if (isNear(x, y, cropRect.centerX(), cropRect.bottom, touchSlop)) return Handle.BOTTOM; + if (isNear(x, y, cropRect.left, cropRect.centerY(), touchSlop)) return Handle.LEFT; + if (isNear(x, y, cropRect.right, cropRect.centerY(), touchSlop)) return Handle.RIGHT; + + // Center third + if (cropRect.contains(x, y)) return Handle.CENTER; + + return null; + } + + private boolean isNear(float x, float y, float targetX, float targetY, float slop) { + float dx = x - targetX; + float dy = y - targetY; + return dx * dx + dy * dy <= slop * slop; + } + + private void moveHandle(Handle handle, float dx, float dy) { + RectF bounds = !imageBounds.isEmpty() ? imageBounds : new RectF(0f, 0f, getWidth(), getHeight()); + + switch (handle) { + case TOP_LEFT: + cropRect.left = constrainMin(cropRect.left + dx, bounds.left, cropRect.right - minCropSize); + cropRect.top = constrainMin(cropRect.top + dy, bounds.top, cropRect.bottom - minCropSize); + break; + case TOP_RIGHT: + cropRect.right = constrainMax(cropRect.right + dx, cropRect.left + minCropSize, bounds.right); + cropRect.top = constrainMin(cropRect.top + dy, bounds.top, cropRect.bottom - minCropSize); + break; + case BOTTOM_LEFT: + cropRect.left = constrainMin(cropRect.left + dx, bounds.left, cropRect.right - minCropSize); + cropRect.bottom = constrainMax(cropRect.bottom + dy, cropRect.top + minCropSize, bounds.bottom); + break; + case BOTTOM_RIGHT: + cropRect.right = constrainMax(cropRect.right + dx, cropRect.left + minCropSize, bounds.right); + cropRect.bottom = constrainMax(cropRect.bottom + dy, cropRect.top + minCropSize, bounds.bottom); + break; + case TOP: + cropRect.top = constrainMin(cropRect.top + dy, bounds.top, cropRect.bottom - minCropSize); + break; + case BOTTOM: + cropRect.bottom = constrainMax(cropRect.bottom + dy, cropRect.top + minCropSize, bounds.bottom); + break; + case LEFT: + cropRect.left = constrainMin(cropRect.left + dx, bounds.left, cropRect.right - minCropSize); + break; + case RIGHT: + cropRect.right = constrainMax(cropRect.right + dx, cropRect.left + minCropSize, bounds.right); + break; + case CENTER: + float newLeft = cropRect.left + dx; + float newTop = cropRect.top + dy; + float newRight = cropRect.right + dx; + float newBottom = cropRect.bottom + dy; + + if (newLeft < bounds.left) { + newRight += bounds.left - newLeft; + newLeft = bounds.left; + } + if (newRight > bounds.right) { + newLeft -= newRight - bounds.right; + newRight = bounds.right; + } + if (newTop < bounds.top) { + newBottom += bounds.top - newTop; + newTop = bounds.top; + } + if (newBottom > bounds.bottom) { + newTop -= newBottom - bounds.bottom; + newBottom = bounds.bottom; + } + + cropRect.set(newLeft, newTop, newRight, newBottom); + break; + } + } + + private float constrainMin(float value, float min, float max) { + return Math.max(min, Math.min(value, max)); + } + + private float constrainMax(float value, float min, float max) { + return Math.max(min, Math.min(value, max)); + } + + private void updateGestureExclusion() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + List exclusionRects = new ArrayList<>(); + int handleExclusion = (int) (48 * density); + int handleSize = (int) cornerTouchSlop; + + if (cropRect.left < handleSize) { + int leftX = 0; + int rightX = handleSize; + for (float cy : new float[]{cropRect.top, cropRect.centerY(), cropRect.bottom}) { + exclusionRects.add(new Rect( + leftX, (int) (cy - handleExclusion / 2.0f), + rightX, (int) (cy + handleExclusion / 2.0f) + )); + } + } + + if (getWidth() - cropRect.right < handleSize) { + int leftX = getWidth() - handleSize; + int rightX = getWidth(); + for (float cy : new float[]{cropRect.top, cropRect.centerY(), cropRect.bottom}) { + exclusionRects.add(new Rect( + leftX, (int) (cy - handleExclusion / 2.0f), + rightX, (int) (cy + handleExclusion / 2.0f) + )); + } + } + setSystemGestureExclusionRects(exclusionRects); + } + } +} diff --git a/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/MainActivity.java b/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/MainActivity.java new file mode 100644 index 0000000..5956751 --- /dev/null +++ b/commons-jpegtran-demo/src/main/java/fr/free/nrw/commons/commons_jpegtran_demo/MainActivity.java @@ -0,0 +1,487 @@ +package fr.free.nrw.commons.commons_jpegtran_demo; + +import android.Manifest; +import android.app.Dialog; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import fr.free.nrw.commons.jpegtran.BlurRegion; +import fr.free.nrw.commons.jpegtran.Jpegtran; +import fr.free.nrw.commons.jpegtran.RotationDegree; + +import android.graphics.RectF; + + +public class MainActivity extends AppCompatActivity { + /* for jpeg_colorspace */ + static final int JCS_UNKNOWN = 0; /* error/unspecified */ + static final int JCS_GRAYSCALE = 1; /* monochrome */ + static final int JCS_RGB = 2; /* red/green/blue, standard RGB (sRGB) */ + static final int JCS_YCbCr = 3; /* Y/Cb/Cr (also known as YUV), standard YCC */ + static final int JCS_CMYK = 4; /* C/M/Y/K */ + static final int JCS_YCCK = 5; /* Y/Cb/Cr/K */ + static final int JCS_BG_RGB = 6; /* big gamut red/green/blue, bg-sRGB */ + static final int JCS_BG_YCC = 7; /* big gamut Y/Cb/Cr, bg-sYCC */ + + static Uri loadUri = null; + static String propertyStr = null; + private ActivityResultLauncher startForResultOpen, startForResultSave; + + private final int REQUEST_CODE_WRITE_PERMISSION = 0x01; + private Jpegtran jpegtran; + private ImageView imageView; + private BlurOverlayView blurOverlayView; + private CropOverlayView cropOverlayView; + private final List tempFiles = new ArrayList<>(); + private Matrix baseImageMatrix = null; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + // Initializing the Jpegtran library. + jpegtran = new Jpegtran(getApplicationContext()); + imageView = findViewById(R.id.imageView); + blurOverlayView = findViewById(R.id.blurOverlayView); + cropOverlayView = findViewById(R.id.cropOverlayView); + blurOverlayView.setImageView(imageView); + + // Register handler for tapping [OPEN] button + Button button_open = (Button) findViewById(R.id.button_open); + button_open.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + getApplicationContext().checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Android 10~ : Request permission to access GEOTAG. + requestPermissions(new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, REQUEST_CODE_WRITE_PERMISSION); + } else { + // Android 4.4~9 : No request is needed. + // or + // The permission is granted + // -> Open file selector + callFileSelection(); + } + // If you access file without SAF, you must get permission WRITE_EXTERNAL_STORAGE. + } + }); + + // Register handler for tapping [SAVE] button + // -> Open file selector + Button button_save = (Button) findViewById(R.id.button_save); + button_save.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + if (loadUri != null) { + callFileSelectionCreate(); + } + } + }); + + if (loadUri == null) { + // If file is not specified yet, disable [SAVE] button. + button_save.setVisibility(View.INVISIBLE); + } else if (propertyStr != null) { + // If propertystr is exist, display it. + TextView textView = (TextView) findViewById(R.id.text_view); + textView.setText(propertyStr); + } + + // Register handler for tapping [ABOUT] button + // -> Display app information dialog + Button button_about = (Button) findViewById(R.id.button_about); + button_about.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + FragmentManager manager = getSupportFragmentManager(); + AboutFragmentDialog abfdialog = new AboutFragmentDialog(); + abfdialog.show(manager, "dialog"); + } + }); + // Blur feature. + Button button_blur = (Button) findViewById(R.id.button_blur); + button_blur.setOnClickListener(v -> { + if (loadUri == null) { + Toast.makeText(this, "Please open an image first", Toast.LENGTH_SHORT).show(); + return; + } + if (blurOverlayView.getVisibility() != View.VISIBLE) { + blurOverlayView.setVisibility(View.VISIBLE); + cropOverlayView.setVisibility(View.GONE); + // Enter zoom mode: save base matrix and sync ImageView + baseImageMatrix = new Matrix(imageView.getImageMatrix()); + imageView.setScaleType(ImageView.ScaleType.MATRIX); + blurOverlayView.setOnZoomChangedListener(vm -> { + Matrix combined = new Matrix(baseImageMatrix); + combined.postConcat(vm); + imageView.setImageMatrix(combined); + }); + Toast.makeText(this, "Blur mode activated. Draw boxes on the image, then click Blur again to apply.", Toast.LENGTH_LONG).show(); + } else { + List regions = blurOverlayView.getMappedBlurRegions(imageView, blurOverlayView); + if (regions.isEmpty()) { + Toast.makeText(this, "Please draw at least one rectangle on the photo", Toast.LENGTH_SHORT).show(); + return; + } + + jpegtran.blur(loadUri, regions, p -> { + if (p != null) { + Bitmap bitmap = BitmapFactory.decodeFile(p.getAbsolutePath()); + runOnUiThread(() -> { + updateLoadUri(p); + blurOverlayView.resetZoom(); + blurOverlayView.setOnZoomChangedListener(null); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + imageView.setImageBitmap(bitmap); + blurOverlayView.clearRegions(); + blurOverlayView.setVisibility(View.GONE); + }); + Toast.makeText(this, "Blurred successfully", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Blur failed", Toast.LENGTH_SHORT).show(); + } + }); + } + }); + + // Crop feature. + Button button_crop = (Button) findViewById(R.id.button_crop); + button_crop.setOnClickListener((v) -> { + if (loadUri == null) { + Toast.makeText(this, "Please open an image first", Toast.LENGTH_SHORT).show(); + return; + } + if (cropOverlayView.getVisibility() != View.VISIBLE) { + // Reset blur zoom if active + if (blurOverlayView.getVisibility() == View.VISIBLE) { + blurOverlayView.resetZoom(); + blurOverlayView.setOnZoomChangedListener(null); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + } + RectF bounds = getBitmapRect(imageView); + cropOverlayView.setImageBounds(bounds.left, bounds.top, bounds.right, bounds.bottom); + cropOverlayView.setVisibility(View.VISIBLE); + blurOverlayView.setVisibility(View.GONE); + Toast.makeText(this, "Crop mode activated. Drag handles to resize, then click Crop again to apply.", Toast.LENGTH_LONG).show(); + } else { + applyCrop(); + } + }); + // Rotate feature. + Button button_rotate = findViewById(R.id.button_rotate); + button_rotate.setOnClickListener(v -> { + if (loadUri == null) { + Toast.makeText(this, "Please open an image first", Toast.LENGTH_SHORT).show(); + return; + } + jpegtran.rotate(loadUri, RotationDegree.ROTATE_90, p -> { + if (p != null) { + Bitmap bitmap = BitmapFactory.decodeFile(p.getAbsolutePath()); + runOnUiThread(() -> { + updateLoadUri(p); + imageView.setImageBitmap(bitmap); + blurOverlayView.clearRegions(); + cropOverlayView.setVisibility(View.GONE); + }); + } else { + runOnUiThread(() -> { + Toast.makeText(this, "Rotation failed", Toast.LENGTH_SHORT).show(); + }); + } + }); + }); + + + // Register handler for open file selector + // -> Get JPEG property and display it + startForResultOpen = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + new ActivityResultCallback() { + @Override + public void onActivityResult(ActivityResult result) { + if (result.getResultCode() == RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri lloadUri = (Uri) data.getData(); + clearTempFiles(); + loadUri = lloadUri; + imageView.setImageURI(lloadUri); + blurOverlayView.clearRegions(); + cropOverlayView.setVisibility(View.GONE); + findViewById(R.id.button_save).setVisibility(View.VISIBLE); + + + assert lloadUri != null; + jpegtran.getProperties(lloadUri, p -> { + // Display properties + TextView textView = (TextView) findViewById(R.id.text_view); + propertyStr = "File name : " + p.fileName + "\n"; + propertyStr += "File size : " + p.fileSize + "\n"; + propertyStr += "Width : " + p.width + "\n" + "Height : " + p.height + "\n"; + propertyStr += "MCU Width : " + p.MCU_Width + "\n" + "MCU Height : " + p.MCU_Height + "\n"; + propertyStr += "Color space : " + p.Color_space + "\n"; + textView.setText(propertyStr); + + }); + } + } + } + }); + + // Register handler for save file selector + // -> Execute Jpegtran + startForResultSave = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + new ActivityResultCallback() { + @Override + public void onActivityResult(ActivityResult result) { + if (result.getResultCode() == RESULT_OK) { + Intent data = result.getData(); + String jniresult; + if (data != null) { + Uri saveUri = (Uri) data.getData(); + ContentResolver resolver = getContentResolver(); + try (ParcelFileDescriptor wparcelFd = resolver.openFileDescriptor(saveUri, "w"); + ParcelFileDescriptor rparcelFd = "file".equals(loadUri.getScheme()) ? + ParcelFileDescriptor.open(new File(Objects.requireNonNull(loadUri.getPath())), + ParcelFileDescriptor.MODE_READ_ONLY) : + resolver.openFileDescriptor(loadUri, "r"); + FileInputStream fis = new FileInputStream(rparcelFd.getFileDescriptor()); + FileOutputStream fos = new FileOutputStream(wparcelFd.getFileDescriptor())) { + // Sequentially read and write to save uri. + byte[] buf = new byte[8192]; + int len; + while ((len = fis.read(buf)) > 0) { + fos.write(buf, 0, len); + } + jniresult = "OK"; + } catch (IOException e) { + jniresult = getString(R.string.mess_error_file); + } + if (jniresult.startsWith("OK")) { + try { + // Workaround : To update mediastore, execute empty write. + OutputStream outstream = resolver.openOutputStream(saveUri, "wa"); + outstream.flush(); + outstream.close(); + } catch (IOException e) { + // Discard exception + // Mediastore may not be updated, but no recovery operation and continuable. + } + Toast.makeText(getApplicationContext(), getString(R.string.mess_success), Toast.LENGTH_LONG).show(); + } else { + // When error is occurred, zero size file still remain. + // It should be deleted. + try { + DocumentsContract.deleteDocument(resolver, saveUri); + } catch (FileNotFoundException e) { + // Discard exception + // The file is not exist, it is OK. + } + Toast.makeText(getApplicationContext(), jniresult, Toast.LENGTH_LONG).show(); + } + } + } + } + }); + } + + /** + * Call file selector for reading JPEG file with readonly mode. + */ + private void callFileSelection() { + Intent intentGallery = new Intent(Intent.ACTION_GET_CONTENT); + intentGallery.addCategory(Intent.CATEGORY_OPENABLE); + intentGallery.setType("image/jpeg"); + startForResultOpen.launch(intentGallery); + } + + /** + * Call file selector for writing JPEG file with create mode. + */ + private void callFileSelectionCreate() { + Intent intentGallery = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intentGallery.addCategory(Intent.CATEGORY_OPENABLE); + intentGallery.setType("image/jpeg"); + // Default file name + intentGallery.putExtra(Intent.EXTRA_TITLE, "output.jpg"); + startForResultSave.launch(intentGallery); + } + + /** + * Handler after requesting permissions. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, int[] grantResults) { + boolean locAccessDeny = false; + // Check requested permission is granted or not. + for (int count = 0; count < grantResults.length; count++) { + if (permissions[count].equals(Manifest.permission.ACCESS_MEDIA_LOCATION)) { + if (grantResults[count] != PackageManager.PERMISSION_GRANTED) { + locAccessDeny = true; + } + } + } + if (locAccessDeny) { + // When permission for accessing GEOTAGs is denied, warn it with toast. + Toast.makeText(getApplicationContext(), getString(R.string.mess_nopermission_location), Toast.LENGTH_LONG).show(); + // GEOTAGs will be lost, but file can be accessed. So continue the process. + } + callFileSelection(); + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + /** + * Open dialog for displaying app information. + * This app uses IJG code. + * This matter must be displayed. + */ + public static class AboutFragmentDialog extends DialogFragment { + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); + builder.setTitle(getString(R.string.button_about)); + builder.setMessage(getString(R.string.app_name) + " \n\n" + getString(R.string.main_ijg)); + builder.setPositiveButton("OK", null); + return builder.create(); + } + } + + // Gets the regions for crop and applies them. + private void applyCrop() { + Drawable drawable = imageView.getDrawable(); + if (drawable == null || loadUri == null) return; + + int imageWidth = drawable.getIntrinsicWidth(); + int imageHeight = drawable.getIntrinsicHeight(); + + Matrix imageMatrix = imageView.getImageMatrix(); + float[] values = new float[9]; + imageMatrix.getValues(values); + + float scaleX = values[Matrix.MSCALE_X]; + float scaleY = values[Matrix.MSCALE_Y]; + float transX = values[Matrix.MTRANS_X]; + float transY = values[Matrix.MTRANS_Y]; + + RectF cropRect = cropOverlayView.getCropRect(); + + float imageLeft = (cropRect.left - transX) / scaleX; + float imageTop = (cropRect.top - transY) / scaleY; + float imageRight = (cropRect.right - transX) / scaleX; + float imageBottom = (cropRect.bottom - transY) / scaleY; + + int x = Math.max(0, (int) imageLeft); + int y = Math.max(0, (int) imageTop); + int width = Math.min(imageWidth - x, (int) (imageRight - imageLeft)); + int height = Math.min(imageHeight - y, (int) (imageBottom - imageTop)); + + if (width <= 0 || height <= 0) { + Toast.makeText(this, "Invalid crop area", Toast.LENGTH_SHORT).show(); + return; + } + + jpegtran.crop(loadUri, width, height, x, y, p -> { + if (p != null) { + Bitmap bitmap = BitmapFactory.decodeFile(p.getAbsolutePath()); + runOnUiThread(() -> { + updateLoadUri(p); + imageView.setImageBitmap(bitmap); + cropOverlayView.setVisibility(View.GONE); + }); + Toast.makeText(this, "Cropped successfully", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Crop failed", Toast.LENGTH_SHORT).show(); + } + }); + } + + private RectF getBitmapRect(ImageView imageView) { + Drawable drawable = imageView.getDrawable(); + if (drawable == null) return new RectF(); + + RectF rect = new RectF(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + Matrix matrix = imageView.getImageMatrix(); + matrix.mapRect(rect); + return rect; + } + + + private void updateLoadUri(File newFile) { + if (newFile == null) return; + + // Save the current loadUri to delete if it is a temp file + if (loadUri != null && "file".equals(loadUri.getScheme())) { + String path = loadUri.getPath(); + if (path != null) { + File oldFile = new File(path); + if (tempFiles.contains(oldFile)) { + oldFile.delete(); + tempFiles.remove(oldFile); + } + } + } + + tempFiles.add(newFile); + loadUri = Uri.parse("file://" + newFile.getAbsolutePath()); + } + + private void clearTempFiles() { + for (File file : tempFiles) { + if (file != null && file.exists()) { + file.delete(); + } + } + tempFiles.clear(); + } + + /** + * When app closed, clear variable. + */ + @Override + protected void onDestroy() { + clearTempFiles(); + if (isFinishing()) { + loadUri = null; + propertyStr = null; + } + super.onDestroy(); + } + +} \ No newline at end of file diff --git a/commons-jpegtran-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml b/commons-jpegtran-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..5453c03 --- /dev/null +++ b/commons-jpegtran-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/commons-jpegtran-demo/src/main/res/drawable/ic_launcher_background.xml b/commons-jpegtran-demo/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d0c86ae --- /dev/null +++ b/commons-jpegtran-demo/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commons-jpegtran-demo/src/main/res/layout/activity_main.xml b/commons-jpegtran-demo/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..84285f3 --- /dev/null +++ b/commons-jpegtran-demo/src/main/res/layout/activity_main.xml @@ -0,0 +1,115 @@ + + + + + + + +