From aea22201c3da56ab7169795d45cb95405ab7b9d7 Mon Sep 17 00:00:00 2001 From: Aris Kotsomitopoulos <60798129+ariskotsomitopoulos@users.noreply.github.com> Date: Tue, 19 Oct 2021 00:20:03 +0300 Subject: [PATCH] Feature/aris/issue 465 scrub exif data (#4248) Implement ImageExifTagRemover to scrub user sensitive data while sending original size photos - Return a not scrubbed file when there is an exception while scrubbing the jpeg file - Improve error handling on image compression --- changelog.d/4264.misc | 1 + changelog.d/465.misc | 1 + dependencies.gradle | 3 + matrix-sdk-android/build.gradle | 3 + .../session/content/ImageCompressor.kt | 9 +- .../session/content/ImageExifTagRemover.kt | 86 +++++++++++++++++++ .../session/content/UploadContentWorker.kt | 9 +- 7 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 changelog.d/4264.misc create mode 100644 changelog.d/465.misc create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt diff --git a/changelog.d/4264.misc b/changelog.d/4264.misc new file mode 100644 index 0000000000..601abbf792 --- /dev/null +++ b/changelog.d/4264.misc @@ -0,0 +1 @@ +Uppon sharing image compression fails, return the original image diff --git a/changelog.d/465.misc b/changelog.d/465.misc new file mode 100644 index 0000000000..709d1f1957 --- /dev/null +++ b/changelog.d/465.misc @@ -0,0 +1 @@ +Scrub user sensitive data like gps location from images when sending on original quality diff --git a/dependencies.gradle b/dependencies.gradle index 1e3c492149..be6ce3f04f 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -130,6 +130,9 @@ ext.libs = [ 'emojiMaterial' : "com.vanniktech:emoji-material:$vanniktechEmoji", 'emojiGoogle' : "com.vanniktech:emoji-google:$vanniktechEmoji" ], + apache:[ + 'commonsImaging' : "org.apache.sanselan:sanselan:0.97-incubator" + ], tests : [ 'kluent' : "org.amshove.kluent:kluent-android:1.68", 'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1", diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 7f5e8eb391..43ca243ec5 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -154,6 +154,9 @@ dependencies { // Video compression implementation 'com.otaliastudios:transcoder:0.10.4' + // Exif data handling + implementation libs.apache.commonsImaging + // Phone number https://github.com/google/libphonenumber implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.35' diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt index 9b01d0a00e..01eb52ff22 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt @@ -20,22 +20,23 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import androidx.exifinterface.media.ExifInterface -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.internal.util.TemporaryFileCreator import timber.log.Timber import java.io.File import javax.inject.Inject internal class ImageCompressor @Inject constructor( - private val temporaryFileCreator: TemporaryFileCreator + private val temporaryFileCreator: TemporaryFileCreator, + private val coroutineDispatchers: MatrixCoroutineDispatchers ) { suspend fun compress( imageFile: File, desiredWidth: Int, desiredHeight: Int, desiredQuality: Int = 80): File { - return withContext(Dispatchers.IO) { + return withContext(coroutineDispatchers.io) { val compressedBitmap = BitmapFactory.Options().run { inJustDecodeBounds = true decodeBitmap(imageFile, this) @@ -52,6 +53,8 @@ internal class ImageCompressor @Inject constructor( destinationFile.outputStream().use { compressedBitmap.compress(Bitmap.CompressFormat.JPEG, desiredQuality, it) } + }.onFailure { + return@withContext imageFile } destinationFile diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt new file mode 100644 index 0000000000..239a768498 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.content + +import kotlinx.coroutines.withContext +import org.apache.sanselan.Sanselan +import org.apache.sanselan.formats.jpeg.JpegImageMetadata +import org.apache.sanselan.formats.jpeg.exifRewrite.ExifRewriter +import org.apache.sanselan.formats.tiff.constants.ExifTagConstants +import org.apache.sanselan.formats.tiff.constants.GPSTagConstants +import org.matrix.android.sdk.api.MatrixCoroutineDispatchers +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.internal.util.TemporaryFileCreator +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject + +/** + * This class is responsible for removing Exif tags from image files + */ + +internal class ImageExifTagRemover @Inject constructor( + private val temporaryFileCreator: TemporaryFileCreator, + private val coroutineDispatchers: MatrixCoroutineDispatchers +) { + + /** + * Remove sensitive exif tags from a jpeg image file. + * Scrubbing exif tags like GPS location and user comments + * @param jpegImageFile The image file to be scrubbed + * @return the new scrubbed image file, or the original file if the operation failed + */ + suspend fun removeSensitiveJpegExifTags(jpegImageFile: File): File = withContext(coroutineDispatchers.io) { + val outputSet = tryOrNull("Unable to read JpegImageMetadata") { + (Sanselan.getMetadata(jpegImageFile) as? JpegImageMetadata)?.exif?.outputSet + } ?: return@withContext jpegImageFile + + tryOrNull("Unable to remove ExifData") { + outputSet.removeField(ExifTagConstants.EXIF_TAG_GPSINFO) + outputSet.removeField(ExifTagConstants.EXIF_TAG_SUBJECT_LOCATION_1) + outputSet.removeField(ExifTagConstants.EXIF_TAG_SUBJECT_LOCATION_2) + outputSet.removeField(ExifTagConstants.EXIF_TAG_USER_COMMENT) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_ALTITUDE) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_ALTITUDE_REF) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LONGITUDE) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LONGITUDE_REF) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LONGITUDE) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LONGITUDE_REF) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LATITUDE) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LATITUDE_REF) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LATITUDE) + outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LATITUDE_REF) + } ?: return@withContext jpegImageFile + + val scrubbedFile = temporaryFileCreator.create() + return@withContext runCatching { + FileOutputStream(scrubbedFile).use { fos -> + val outputStream = BufferedOutputStream(fos) + ExifRewriter().updateExifMetadataLossless(jpegImageFile, outputStream, outputSet) + } + }.fold( + onSuccess = { + scrubbedFile + }, + onFailure = { + scrubbedFile.delete() + jpegImageFile + } + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt index 6fd92047b3..0c5a90ca60 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt @@ -64,7 +64,7 @@ private data class NewAttachmentAttributes( * Possible next worker : Always [MultipleEventSendingDispatcherWorker] */ internal class UploadContentWorker(val context: Context, params: WorkerParameters) : - SessionSafeCoroutineWorker(context, params, Params::class.java) { + SessionSafeCoroutineWorker(context, params, Params::class.java) { @JsonClass(generateAdapter = true) internal data class Params( @@ -81,6 +81,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter @Inject lateinit var fileService: DefaultFileService @Inject lateinit var cancelSendTracker: CancelSendTracker @Inject lateinit var imageCompressor: ImageCompressor + @Inject lateinit var imageExitTagRemover: ImageExifTagRemover @Inject lateinit var videoCompressor: VideoCompressor @Inject lateinit var thumbnailExtractor: ThumbnailExtractor @Inject lateinit var localEchoRepository: LocalEchoRepository @@ -114,7 +115,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter } val attachment = params.attachment - val filesToDelete = mutableListOf() + val filesToDelete = hashSetOf() return try { val inputStream = context.contentResolver.openInputStream(attachment.queryUri) @@ -219,6 +220,10 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter } } } + } else if (attachment.type == ContentAttachmentData.Type.IMAGE && !params.compressBeforeSending) { + fileToUpload = imageExitTagRemover.removeSensitiveJpegExifTags(workingFile) + .also { filesToDelete.add(it) } + newAttachmentAttributes = newAttachmentAttributes.copy(newFileSize = fileToUpload.length()) } else { fileToUpload = workingFile // Fix: OpenableColumns.SIZE may return -1 or 0