Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

imageCaptureProcessing maintain EXIF #2902

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ private static Pair<File, String> moveAndScaleImage(File originalImage, boolean
if (currentWidget != null) {
int maxDimen = currentWidget.getMaxDimen();
if (maxDimen != -1) {
savedScaledImage = FileUtil.scaleAndSaveImage(originalImage, tempFilePathForScaledImage, maxDimen);
File tempFile = new File(tempFilePathForScaledImage);
savedScaledImage = FileUtil.scaleAndSaveImageWithExif(originalImage, tempFile, maxDimen);
}
}
}
Expand All @@ -66,17 +67,72 @@ private static Pair<File, String> moveAndScaleImage(File originalImage, boolean
// Encrypt the scaled or original image to final path
if (HiddenPreferences.isMediaCaptureEncryptionEnabled()) {
finalFilePath = finalFilePath + MediaWidget.AES_EXTENSION;
File sourceFile = new File(sourcePath);
File destFile = new File(finalFilePath);

try {
// Extract EXIF data before encryption
ExifInterface sourceExif = null;
String mimeType = FileUtil.getMimeType(sourcePath);
if (mimeType != null && mimeType.startsWith("image/")) {
sourceExif = new ExifInterface(sourcePath);
}

// Perform encryption
EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey());

// If we had EXIF data, store it in a companion file
if (sourceExif != null) {
String exifPath = finalFilePath + ".exif";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit unsure how it's working. A few questions reg. encrypted images -

  1. Curious why do we need to create a separate .exif file here to retain exif data ?

  2. Before uploading images to the server, we decrypt them first by using a EncryptedFileBody. Do we also need to upload the .exif file there to be able to retain exif data on server ?

  3. In your testing, have you been able to test the exif retention for final image on server with encyrption enabled ?

ExifInterface destExif = new ExifInterface(exifPath);

// Copy all EXIF tags
String[] tagsToPreserve = {
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_AREA_INFORMATION,
ExifInterface.TAG_DATETIME,
ExifInterface.TAG_DATETIME_DIGITIZED,
ExifInterface.TAG_DATETIME_ORIGINAL,
ExifInterface.TAG_OFFSET_TIME,
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
ExifInterface.TAG_COPYRIGHT,
ExifInterface.TAG_IMAGE_DESCRIPTION,
ExifInterface.TAG_EXIF_VERSION,
ExifInterface.TAG_ORIENTATION
};

for (String tag : tagsToPreserve) {
String value = sourceExif.getAttribute(tag);
if (value != null) {
destExif.setAttribute(tag, value);
}
}
Comment on lines +90 to +117
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we reuse code from copyExifData here ?

destExif.saveAttributes();

// Encrypt the EXIF data file as well
EncryptionIO.encryptFile(exifPath, exifPath + MediaWidget.AES_EXTENSION,
formEntryActivity.getSymetricKey());
// Delete the unencrypted EXIF file
new File(exifPath).delete();
}
melmathari marked this conversation as resolved.
Show resolved Hide resolved
} catch (Exception e) {
throw new IOException("Failed to encrypt " + sourcePath +
" to " + finalFilePath, e);
throw new IOException("Failed to encrypt image and preserve EXIF data from " +
sourcePath + " to " + finalFilePath, e);
}
} else {
try {
FileUtil.copyFile(sourcePath, finalFilePath);
FileUtil.copyFileWithExifData(new File(sourcePath), new File(finalFilePath));
} catch (Exception e) {
throw new IOException("Failed to rename " + sourcePath + " to " + finalFilePath);
throw new IOException("Failed to copy image with EXIF data from " +
sourcePath + " to " + finalFilePath, e);
}
}

Expand All @@ -92,10 +148,10 @@ private static File makeRawCopy(File originalImage, String instanceFolder, Strin
}
File rawImageFile = new File(rawDirPath + "/" + imageFilename);
try {
FileUtil.copyFile(originalImage, rawImageFile);
FileUtil.copyFileWithExifData(originalImage, rawImageFile);
} catch (Exception e) {
throw new IOException("Failed to rename " + originalImage.getAbsolutePath() +
" to " + rawImageFile.getAbsolutePath());
throw new IOException("Failed to copy image with EXIF data from " +
originalImage.getAbsolutePath() + " to " + rawImageFile.getAbsolutePath(), e);
}
return rawImageFile;
}
Expand Down
142 changes: 134 additions & 8 deletions app/src/org/commcare/utils/FileUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.media.ExifInterface;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
Expand Down Expand Up @@ -64,6 +65,12 @@ public class FileUtil {

private static final String LOG_TOKEN = "cc-file-util";

/**
* Deletes a file or directory.
*
* @param path The path to the file or directory to delete
* @return true if the file and all of its contents were deleted successfully, false otherwise
*/
public static boolean deleteFileOrDir(String path) {
return deleteFileOrDir(new File(path));
}
Expand All @@ -83,6 +90,13 @@ public static boolean deleteFileOrDir(File f) {
return f.delete();
}

/**
* Cleans up file paths.
*
* @param fullPath The full path to the file
* @param extendedPath The extended path to stop at
* @return true if the file path was cleaned up successfully, false otherwise
*/
public static boolean cleanFilePath(String fullPath, String extendedPath) {
//There are actually a few things that can go wrong here, should be careful

Expand Down Expand Up @@ -397,7 +411,6 @@ public static void ensureFilePathExists(File f) {
* if we are on KitKat we need use the new API to find the mounted roots, then append our application
* specific path that we're allowed to write to
*/
@SuppressLint("NewApi")
private static String getExternalDirectoryKitKat(Context c) {
File[] extMounts = c.getExternalFilesDirs(null);
// first entry is emualted storage. Second if it exists is secondary (real) SD.
Expand Down Expand Up @@ -494,8 +507,9 @@ public static String getMd5Hash(File file) {

BigInteger number = new BigInteger(1, messageDigest);
String md5 = number.toString(16);
while (md5.length() < 32)
while (md5.length() < 32) {
md5 = "0" + md5;
}
is.close();
return md5;

Expand Down Expand Up @@ -612,7 +626,8 @@ public static boolean scaleAndSaveImage(File originalImage, String finalFilePath
Pair<Bitmap, Boolean> bitmapAndScaledBool = MediaUtil.inflateImageSafe(originalImage.getAbsolutePath());
if (bitmapAndScaledBool.second) {
Logger.log(LogTypes.TYPE_FORM_ENTRY,
"An image captured during form entry was too large to be processed at its original size, and had to be downsized");
"An image captured during form entry was too large to be processed at its original size, " +
"and had to be downsized");
}
Bitmap scaledBitmap = getBitmapScaledByMaxDimen(bitmapAndScaledBool.first, maxDimen);
if (scaledBitmap != null) {
Expand Down Expand Up @@ -723,7 +738,9 @@ public static Uri getUriForExternalFile(Context context, File file) {
* @param dstFile destination File where we need to copy the inputStream
*/
public static void copyFile(InputStream inputStream, File dstFile) throws IOException {
if (inputStream == null) return;
if (inputStream == null) {
throw new IOException("InputStream is null. Cannot copy file.");
}
OutputStream outputStream = new FileOutputStream(dstFile);
StreamsUtil.writeFromInputToOutputUnmanaged(inputStream, outputStream);
inputStream.close();
Expand Down Expand Up @@ -844,17 +861,126 @@ public static void addMediaToGallery(Context context, File file) throws
}

/**
* Returns true only when we're certain that the file size is too large.
* <p> https://developer.android.com/training/secure-file-sharing/retrieve-info.html#RetrieveFileInfo
* Checks if a file referenced by a content URI exceeds the maximum allowed upload size
* <p>
* @param contentResolver ContentResolver to query the file size
* @param uri Content URI of the file to check
* @return true if the file size exceeds FormUploadUtil.MAX_BYTES, false otherwise or if size cannot be determined
* @see FormUploadUtil#MAX_BYTES
* @see <a href="https://developer.android.com/training/secure-file-sharing/retrieve-info.html#RetrieveFileInfo">Android docs</a>
melmathari marked this conversation as resolved.
Show resolved Hide resolved
*/
public static boolean isFileTooLargeToUpload(ContentResolver contentResolver, Uri uri) {
try (Cursor returnCursor = contentResolver.query(uri, null, null, null, null)) {
if (returnCursor == null || returnCursor.getCount() <= 0) {
return false;
}
int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE);
returnCursor.moveToFirst();
return returnCursor.getLong(sizeIndex) > FormUploadUtil.MAX_BYTES;
if (sizeIndex == -1) {
return false;
}
if (returnCursor.moveToFirst()) {
return returnCursor.getLong(sizeIndex) > FormUploadUtil.MAX_BYTES;
} else {
return false;
}
Comment on lines +878 to +885
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for making this better.

}
}

shubham1g5 marked this conversation as resolved.
Show resolved Hide resolved
/**
* Copies a file from source to destination while preserving EXIF metadata for image files.
* For non-image files, performs a regular file copy.
*
* @param sourceFile The source file to copy from
* @param destFile The destination file to copy to
* @throws IOException If there is an error during file copying or EXIF data transfer
*/
public static void copyFileWithExifData(File sourceFile, File destFile) throws IOException {
// First copy the file normally
copyFile(sourceFile, destFile);

// Only try to copy EXIF data for image files
String mimeType = getMimeType(sourceFile.getAbsolutePath());
if (mimeType != null && mimeType.startsWith("image/")) {
try {
copyExifData(sourceFile.getAbsolutePath(), destFile.getAbsolutePath());
} catch (IOException e) {
throw new IOException("Failed to copy EXIF data", e);
}
}
}

private static void copyExifData(String sourcePath, String destPath) {
try {
ExifInterface source = new ExifInterface(sourcePath);
ExifInterface dest = new ExifInterface(destPath);

String[] tagsToPreserve = {
// GPS data
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_AREA_INFORMATION,

// Timestamp data
ExifInterface.TAG_DATETIME,
ExifInterface.TAG_DATETIME_DIGITIZED,
ExifInterface.TAG_DATETIME_ORIGINAL,
ExifInterface.TAG_OFFSET_TIME,
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,

// Image metadata
ExifInterface.TAG_COPYRIGHT,
ExifInterface.TAG_IMAGE_DESCRIPTION,
ExifInterface.TAG_EXIF_VERSION,
ExifInterface.TAG_ORIENTATION
};

for (String tag : tagsToPreserve) {
String value = source.getAttribute(tag);
if (value != null) {
dest.setAttribute(tag, value);
}
}

dest.saveAttributes();
} catch (IOException e) {
// Log but don't fail if EXIF copying fails
Logger.log(LogTypes.TYPE_WARNING_NETWORK,
String.format("Failed to copy EXIF data from %s to %s: %s (Error: %s)",
sourcePath, destPath, e.getMessage(), e.getClass().getSimpleName()));
}
}

/**
* Scales an image if needed and preserves its EXIF metadata in the process.
*
* @param sourceFile The original image file
* @param destFile The destination file where the scaled image will be saved
* @param maxDimen The maximum dimension (width or height) allowed for the scaled image
* @return true if the operation was successful (either scaled with EXIF preserved or
* copied with EXIF if scaling wasn't needed)
* @throws IOException If there is an error during scaling, file operations or EXIF data transfer
*/
public static boolean scaleAndSaveImageWithExif(File sourceFile, File destFile, int maxDimen)
throws IOException {
// First scale the image
boolean scaled = scaleAndSaveImage(sourceFile, destFile.getAbsolutePath(), maxDimen);

if (!scaled) {
// If scaling was not needed, copy the source file to destination with EXIF data
copyFileWithExifData(sourceFile, destFile);
return true;
} else {
// Copy EXIF data from source to scaled image
copyExifData(sourceFile.getAbsolutePath(), destFile.getAbsolutePath());
}

return true;
}
melmathari marked this conversation as resolved.
Show resolved Hide resolved
}