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
This commit is contained in:
Aris Kotsomitopoulos 2021-10-19 00:20:03 +03:00 committed by GitHub
parent 2a47acc68a
commit aea22201c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 107 additions and 5 deletions

1
changelog.d/4264.misc Normal file
View file

@ -0,0 +1 @@
Uppon sharing image compression fails, return the original image

1
changelog.d/465.misc Normal file
View file

@ -0,0 +1 @@
Scrub user sensitive data like gps location from images when sending on original quality

View file

@ -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",

View file

@ -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'

View file

@ -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

View file

@ -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
}
)
}
}

View file

@ -64,7 +64,7 @@ private data class NewAttachmentAttributes(
* Possible next worker : Always [MultipleEventSendingDispatcherWorker]
*/
internal class UploadContentWorker(val context: Context, params: WorkerParameters) :
SessionSafeCoroutineWorker<UploadContentWorker.Params>(context, params, Params::class.java) {
SessionSafeCoroutineWorker<UploadContentWorker.Params>(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<File>()
val filesToDelete = hashSetOf<File>()
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