diff --git a/modules/pictures/src/main/java/com/gluonhq/attach/pictures/impl/AndroidPicturesService.java b/modules/pictures/src/main/java/com/gluonhq/attach/pictures/impl/AndroidPicturesService.java index c8e29614..322dfa80 100644 --- a/modules/pictures/src/main/java/com/gluonhq/attach/pictures/impl/AndroidPicturesService.java +++ b/modules/pictures/src/main/java/com/gluonhq/attach/pictures/impl/AndroidPicturesService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, 2022, Gluon + * Copyright (c) 2016, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -33,14 +33,10 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.SimpleObjectProperty; -import javafx.scene.SnapshotParameters; import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.paint.Color; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.util.Optional; import java.util.logging.Logger; @@ -93,6 +89,7 @@ public class AndroidPicturesService implements PicturesService { private static final ObjectProperty imageFile = new SimpleObjectProperty<>(); private static final ReadOnlyObjectWrapper imageProperty = new ReadOnlyObjectWrapper<>(); + private static final int MAX_IMAGE_DIMENSION = 1280; private static ObjectProperty result; private static boolean enteredLoop; @@ -149,31 +146,35 @@ public ReadOnlyObjectProperty imageProperty() { public static native void selectPicture(); // callback - public static void setResult(String filePath, int rotate) { - LOG.fine("Got photo file at: " + filePath); - File photoFile = new File(filePath); - imageFile.set(photoFile); - Image initialImage = null; - try { - initialImage = new Image(new FileInputStream(photoFile)); - } catch (FileNotFoundException e) { - LOG.severe("GalleryActivity: file not found: " + e); + /** + * Called from native code with two file paths: + * @param originalFilePath the full-resolution original file (for {@link #getImageFile()}) + * @param processedFilePath the preprocessed (scaled+rotated) file (for {@link Image} loading) + */ + public static void setResult(String originalFilePath, String processedFilePath) { + LOG.fine("Got photo file at: " + originalFilePath + " (processed: " + processedFilePath + ")"); + File originalFile = new File(originalFilePath); + File processedFile = new File(processedFilePath); + imageFile.set(originalFile); + + // Release the old image reference and try to free resources + imageProperty.setValue(null); + // ugly, but effective preventing vram pool from growing when taking many pictures + System.gc(); + + Image image = null; + try (FileInputStream fis = new FileInputStream(processedFile)) { + image = new Image(fis, MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, true, true); + } catch (Exception e) { + LOG.severe("GalleryActivity: error loading image: " + e); } - if (enteredLoop && (initialImage == null || rotate == 0)) { - result.set(initialImage); + + final Image finalImage = image; + if (enteredLoop) { + result.set(finalImage); } - final Image finalImage = initialImage; Platform.runLater(() -> { - if (finalImage != null && rotate != 0) { - Image image = rotateImage(finalImage, rotate); - if (enteredLoop) { - result.set(image); - } else { - imageProperty.setValue(image); - } - } else { - imageProperty.setValue(finalImage); - } + imageProperty.setValue(finalImage); if (enteredLoop) { enteredLoop = false; try { @@ -184,18 +185,4 @@ public static void setResult(String filePath, int rotate) { } }); } - - private static Image rotateImage(Image image, int rotate) { - if (image == null || rotate == 0) { - return image; - } - ImageView iv = new ImageView(image); - iv.setFitWidth(1280); - iv.setFitHeight(1280); - iv.setPreserveRatio(true); - iv.setRotate(rotate); - SnapshotParameters params = new SnapshotParameters(); - params.setFill(Color.TRANSPARENT); - return iv.snapshot(params, null); - } } diff --git a/modules/pictures/src/main/native/android/c/pictures.c b/modules/pictures/src/main/native/android/c/pictures.c index 921f6b71..69a10da0 100644 --- a/modules/pictures/src/main/native/android/c/pictures.c +++ b/modules/pictures/src/main/native/android/c/pictures.c @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2021, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -38,7 +38,7 @@ static jmethodID jPicturesServiceSelectPictureMethod; void initializePicturesGraalHandles(JNIEnv *graalEnv) { jGraalPicturesClass = (*graalEnv)->NewGlobalRef(graalEnv, (*graalEnv)->FindClass(graalEnv, "com/gluonhq/attach/pictures/impl/AndroidPicturesService")); - jGraalSendPhotoFileMethod = (*graalEnv)->GetStaticMethodID(graalEnv, jGraalPicturesClass, "setResult", "(Ljava/lang/String;I)V"); + jGraalSendPhotoFileMethod = (*graalEnv)->GetStaticMethodID(graalEnv, jGraalPicturesClass, "setResult", "(Ljava/lang/String;Ljava/lang/String;)V"); } void initializePicturesDalvikHandles() { @@ -101,14 +101,17 @@ JNIEXPORT void JNICALL Java_com_gluonhq_attach_pictures_impl_AndroidPicturesServ // From Dalvik to native // /////////////////////////// -JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_DalvikPicturesService_sendPhotoFile(JNIEnv *env, jobject service, jstring path, jint rotate) { +JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_DalvikPicturesService_sendPhotoFile(JNIEnv *env, jobject service, jstring originalPath, jstring processedPath) { if (isDebugAttach()) { ATTACH_LOG_FINE("Send Photo File\n"); } - const char *pathChars = (*env)->GetStringUTFChars(env, path, NULL); + const char *originalChars = (*env)->GetStringUTFChars(env, originalPath, NULL); + const char *processedChars = (*env)->GetStringUTFChars(env, processedPath, NULL); ATTACH_GRAAL(); - jstring jpath = (*graalEnv)->NewStringUTF(graalEnv, pathChars); - (*graalEnv)->CallStaticVoidMethod(graalEnv, jGraalPicturesClass, jGraalSendPhotoFileMethod, jpath, rotate); + jstring jOriginal = (*graalEnv)->NewStringUTF(graalEnv, originalChars); + jstring jProcessed = (*graalEnv)->NewStringUTF(graalEnv, processedChars); + (*graalEnv)->CallStaticVoidMethod(graalEnv, jGraalPicturesClass, jGraalSendPhotoFileMethod, jOriginal, jProcessed); DETACH_GRAAL(); - // (*env)->ReleaseStringUTFChars(env, jpath, jpathChars); + (*env)->ReleaseStringUTFChars(env, originalPath, originalChars); + (*env)->ReleaseStringUTFChars(env, processedPath, processedChars); } \ No newline at end of file diff --git a/modules/pictures/src/main/native/android/dalvik/DalvikPicturesService.java b/modules/pictures/src/main/native/android/dalvik/DalvikPicturesService.java index fa87be18..c9a917af 100644 --- a/modules/pictures/src/main/native/android/dalvik/DalvikPicturesService.java +++ b/modules/pictures/src/main/native/android/dalvik/DalvikPicturesService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2024, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,6 +31,9 @@ import android.app.Activity; import android.content.Intent; import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; import android.media.ExifInterface; import android.media.MediaScannerConnection; import android.net.Uri; @@ -41,6 +44,7 @@ import android.util.Log; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; @@ -64,6 +68,10 @@ public class DalvikPicturesService { private final String authority; private String photoPath; + private static final int TARGET_SIZE = 1280; + private static final int JPEG_QUALITY = 85; + private static final byte[] DECODE_BUFFER = new byte[16 * 1024]; + public DalvikPicturesService(Activity activity) { this.activity = activity; this.debug = Util.isDebug(); @@ -92,6 +100,7 @@ public void takePhoto(final boolean savePhoto) { Log.v(TAG, "Permission verification failed: Camera disabled"); return; } + clearCache(); Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); @@ -132,12 +141,18 @@ public void gotActivityResult (int requestCode, int resultCode, Intent intent) { if (debug) { Log.v(TAG, "Image file located at " + photoFile.getAbsolutePath() + " with rotation: " + imageRotation); } - sendPhotoFile(photoFile.getAbsolutePath(), imageRotation); + + String originalPath = photoFile.getAbsolutePath(); if (savePhoto) { - // media scanner to rescan DIRECTORY_PICTURES after an image is saved/deleted + // Saved photos: keep the original file untouched in + // DIRECTORY_PICTURES, scan it into the gallery, and + // send a preprocessed cache copy for display. MediaScannerConnection.scanFile(activity, new String[]{photoFile.toString()}, null, null); + photoFile = copyToCache(photoFile); } + preprocessImage(photoFile, imageRotation); + sendPhotoFile(originalPath, photoFile.getAbsolutePath()); } else { Log.e(TAG, "Picture file doesn't exist for: " + photoFile.getAbsolutePath()); } @@ -162,6 +177,8 @@ public void gotActivityResult (int requestCode, int resultCode, Intent intent) { } private void selectPicture() { + clearCache(); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType("image/*"); @@ -193,7 +210,9 @@ public void gotActivityResult(int requestCode, int resultCode, Intent intent) { if (debug) { Log.v(TAG, "Image file located at " + cachePhotoFile.getAbsolutePath() + " with rotation: " + imageRotation); } - sendPhotoFile(cachePhotoFile.getAbsolutePath(), imageRotation); + String originalPath = cachePhotoFile.getAbsolutePath(); + preprocessImage(cachePhotoFile, imageRotation); + sendPhotoFile(originalPath, cachePhotoFile.getAbsolutePath()); } } } @@ -232,7 +251,9 @@ private int getImageRotation(Uri uri) { try { ExifInterface ei; if (Build.VERSION.SDK_INT > 23) { - ei = new ExifInterface(activity.getContentResolver().openInputStream(uri)); + try (InputStream is = activity.getContentResolver().openInputStream(uri)) { + ei = new ExifInterface(is); + } } else { ei = new ExifInterface(uri.getPath()); } @@ -258,7 +279,7 @@ private File copyFile(Uri uri) { File selectedFile = new File(activity.getCacheDir(), getImageName(uri)); try (InputStream is = activity.getContentResolver().openInputStream(uri); OutputStream os = new FileOutputStream(selectedFile)) { - byte[] buffer = new byte[8 * 1024]; + byte[] buffer = new byte[32 * 1024]; int bytesRead; while ((bytesRead = is.read(buffer)) != -1) { os.write(buffer, 0, bytesRead); @@ -271,7 +292,105 @@ private File copyFile(Uri uri) { return selectedFile; } + private File copyToCache(File source) { + File dest = new File(activity.getCacheDir(), "display_" + source.getName()); + try (InputStream is = new FileInputStream(source); OutputStream os = new FileOutputStream(dest)) { + byte[] buffer = new byte[32 * 1024]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + os.write(buffer, 0, bytesRead); + } + } catch (IOException ex) { + Log.e(TAG, "copyToCache failed: " + ex.getMessage()); + return source; // fall back to original + } + return dest; + } + + /** + * Scales and rotates the image file in place, with sub sampling and lower jpg quality, + * to reduce memory footprint + */ + private void preprocessImage(File imageFile, int rotation) { + try { + // 1. Read dimensions only + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + opts.inTempStorage = DECODE_BUFFER; + BitmapFactory.decodeFile(imageFile.getAbsolutePath(), opts); + + // 2. Calculate inSampleSize + int srcW = opts.outWidth; + int srcH = opts.outHeight; + if (rotation == 90 || rotation == 270) { + srcW = opts.outHeight; + srcH = opts.outWidth; + } + opts.inSampleSize = calculateInSampleSize(srcW, srcH, TARGET_SIZE); + opts.inJustDecodeBounds = false; + opts.inPreferredConfig = Bitmap.Config.RGB_565; + opts.inTempStorage = DECODE_BUFFER; + + // 3. Load sub-sampled bitmap + Bitmap bitmap = BitmapFactory.decodeFile(imageFile.getAbsolutePath(), opts); + if (bitmap == null) { + Log.e(TAG, "preprocessImage: failed to decode bitmap"); + return; + } + + try { + // 4. Build a single Matrix for scale + rotate combined + Matrix matrix = new Matrix(); + float scale = Math.min( + (float) TARGET_SIZE / bitmap.getWidth(), + (float) TARGET_SIZE / bitmap.getHeight()); + if (scale < 1.0f) { + matrix.postScale(scale, scale); + } + if (rotation != 0) { + // Rotate around the center of the image + float cx = bitmap.getWidth() * Math.max(scale, 1.0f) / 2f; + float cy = bitmap.getHeight() * Math.max(scale, 1.0f) / 2f; + matrix.postRotate(rotation, cx, cy); + } + + // 5. Apply combined transform + if (!matrix.isIdentity()) { + Bitmap transformed = Bitmap.createBitmap(bitmap, 0, 0, + bitmap.getWidth(), bitmap.getHeight(), matrix, true); + bitmap.recycle(); + bitmap = transformed; + } + + // 6. Write processed image back to file + try (FileOutputStream fos = new FileOutputStream(imageFile)) { + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos); + } + if (debug) { + Log.v(TAG, "preprocessImage: wrote " + bitmap.getWidth() + "x" + bitmap.getHeight() + + " (rotation=" + rotation + ") to " + imageFile.getName()); + } + } finally { + bitmap.recycle(); + } + } catch (Exception e) { + Log.e(TAG, "preprocessImage failed, falling back to original: " + e.getMessage()); + } + } + + private static int calculateInSampleSize(int width, int height, int targetSize) { + int inSampleSize = 1; + if (height > targetSize || width > targetSize) { + int halfH = height / 2; + int halfW = width / 2; + while ((halfH / inSampleSize) >= targetSize && (halfW / inSampleSize) >= targetSize) { + inSampleSize *= 2; + } + } + return inSampleSize; + } + // native - private native void sendPhotoFile(String filePath, int rotate); + private native void sendPhotoFile(String originalFilePath, String processedFilePath); } diff --git a/modules/pictures/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json b/modules/pictures/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json index 7c15cbc7..f0baded1 100644 --- a/modules/pictures/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json +++ b/modules/pictures/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json @@ -1,6 +1,6 @@ [ { "name" : "com.gluonhq.attach.pictures.impl.AndroidPicturesService", - "methods":[{"name":"setResult","parameterTypes":["java.lang.String", "int"] }] + "methods":[{"name":"setResult","parameterTypes":["java.lang.String","java.lang.String"] }] } ] \ No newline at end of file