diff --git a/androidshared/build.gradle b/androidshared/build.gradle index 6671d6c56ac..e420bcb0a0c 100644 --- a/androidshared/build.gradle +++ b/androidshared/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation Dependencies.androidx_fragment_ktx implementation Dependencies.androidx_preference_ktx implementation Dependencies.timber + implementation Dependencies.androidx_exinterface testImplementation project(':testshared') testImplementation Dependencies.junit @@ -70,5 +71,8 @@ dependencies { testImplementation Dependencies.robolectric testImplementation Dependencies.mockito_kotlin + androidTestImplementation Dependencies.androidx_test_ext_junit + androidTestImplementation Dependencies.junit + debugImplementation project(':fragmentstest') } diff --git a/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt b/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt new file mode 100644 index 00000000000..866a171d49f --- /dev/null +++ b/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2017 Nafundi + * + * 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.odk.collect.androidshared.bitmap + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class ImageCompressorTest { + private lateinit var testImagePath: String + private val imageCompressor = ImageCompressor + + @Test + fun imageShouldNotBeChangedIfMaxPixelsIsZero() { + saveTestBitmap(3000, 2000) + imageCompressor.execute(testImagePath, 0) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(3000, equalTo(image.width)) + assertThat(2000, equalTo(image.height)) + } + + @Test + fun imageShouldNotBeChangedIfMaxPixelsIsSmallerThanZero() { + saveTestBitmap(3000, 2000) + imageCompressor.execute(testImagePath, -10) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(3000, equalTo(image.width)) + assertThat(2000, equalTo(image.height)) + } + + @Test + fun imageShouldNotBeChangedIfMaxPixelsIsNotSmallerThanTheEdgeWhenWidthIsBiggerThanHeight() { + saveTestBitmap(3000, 2000) + imageCompressor.execute(testImagePath, 3000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(3000, equalTo(image.width)) + assertThat(2000, equalTo(image.height)) + } + + @Test + fun imageShouldNotBeChangedIfMaxPixelsIsNotSmallerThanTheLongEdgeWhenWidthIsSmallerThanHeight() { + saveTestBitmap(2000, 3000) + imageCompressor.execute(testImagePath, 4000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(2000, equalTo(image.width)) + assertThat(3000, equalTo(image.height)) + } + + @Test + fun imageShouldNotBeChangedIfMaxPixelsIsNotSmallerThanTheLongEdgeWhenWidthEqualsHeight() { + saveTestBitmap(3000, 3000) + imageCompressor.execute(testImagePath, 3000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(3000, equalTo(image.width)) + assertThat(3000, equalTo(image.height)) + } + + @Test + fun imageShouldBeCompressedIfMaxPixelsIsSmallerThanTheLongEdgeWhenWidthIsBiggerThanHeight() { + saveTestBitmap(4000, 3000) + imageCompressor.execute(testImagePath, 2000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(2000, equalTo(image.width)) + assertThat(1500, equalTo(image.height)) + } + + @Test + fun imageShouldBeCompressedIfMaxPixelsIsSmallerThanTheLongEdgeWhenWidthIsSmallerThanHeight() { + saveTestBitmap(3000, 4000) + imageCompressor.execute(testImagePath, 2000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(1500, equalTo(image.width)) + assertThat(2000, equalTo(image.height)) + } + + @Test + fun imageShouldBeCompressedIfMaxPixelsIsSmallerThanTheLongEdgeWhenWidthEqualsHeight() { + saveTestBitmap(3000, 3000) + imageCompressor.execute(testImagePath, 2000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(2000, equalTo(image.width)) + assertThat(2000, equalTo(image.height)) + } + + @Test + fun keepExifAfterScaling() { + val attributes = mutableMapOf( + // supported exif tags + ExifInterface.TAG_DATETIME to "2014:01:23 14:57:18", + ExifInterface.TAG_DATETIME_ORIGINAL to "2014:01:23 14:57:18", + ExifInterface.TAG_DATETIME_DIGITIZED to "2014:01:23 14:57:18", + ExifInterface.TAG_OFFSET_TIME to "+1:00", + ExifInterface.TAG_OFFSET_TIME_ORIGINAL to "+1:00", + ExifInterface.TAG_OFFSET_TIME_DIGITIZED to "+1:00", + ExifInterface.TAG_SUBSEC_TIME to "First photo", + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to "0", + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to "0", + ExifInterface.TAG_IMAGE_DESCRIPTION to "Photo from Poland", + ExifInterface.TAG_MAKE to "OLYMPUS IMAGING CORP", + ExifInterface.TAG_MODEL to "STYLUS1", + ExifInterface.TAG_SOFTWARE to "Version 1.0", + ExifInterface.TAG_ARTIST to "Grzegorz", + ExifInterface.TAG_COPYRIGHT to "G", + ExifInterface.TAG_MAKER_NOTE to "OLYMPUS", + ExifInterface.TAG_USER_COMMENT to "First photo", + ExifInterface.TAG_IMAGE_UNIQUE_ID to "123456789", + ExifInterface.TAG_CAMERA_OWNER_NAME to "John", + ExifInterface.TAG_BODY_SERIAL_NUMBER to "987654321", + ExifInterface.TAG_GPS_ALTITUDE to "41/1", + ExifInterface.TAG_GPS_ALTITUDE_REF to "0", + ExifInterface.TAG_GPS_DATESTAMP to "2014:01:23", + ExifInterface.TAG_GPS_TIMESTAMP to "14:57:18", + ExifInterface.TAG_GPS_LATITUDE to "50/1,49/1,8592/1000", + ExifInterface.TAG_GPS_LATITUDE_REF to "N", + ExifInterface.TAG_GPS_LONGITUDE to "0/1,8/1,12450/1000", + ExifInterface.TAG_GPS_LONGITUDE_REF to "W", + ExifInterface.TAG_GPS_SATELLITES to "8", + ExifInterface.TAG_GPS_STATUS to "A", + ExifInterface.TAG_ORIENTATION to "1", + + // unsupported exif tags + ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to "5", + ExifInterface.TAG_DNG_VERSION to "100", + ) + + saveTestBitmap(3000, 4000, attributes) + imageCompressor.execute(testImagePath, 2000) + + val exifData = ExifInterface(testImagePath) + for (attributeName in attributes.keys) { + if (attributeName == ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH || + attributeName == ExifInterface.TAG_DNG_VERSION + ) { + assertThat(exifData.getAttribute(attributeName), equalTo(null)) + } else { + assertThat(exifData.getAttribute(attributeName), equalTo(attributes[attributeName])) + } + } + } + + @Test + fun verifyNoRotationAppliedForExifRotation() { + val attributes = mapOf(ExifInterface.TAG_ORIENTATION to ExifInterface.ORIENTATION_ROTATE_90.toString()) + saveTestBitmap(3000, 4000, attributes) + imageCompressor.execute(testImagePath, 4000) + + val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options())!! + + assertThat(3000, equalTo(image.width)) + assertThat(4000, equalTo(image.height)) + } + + private fun saveTestBitmap(width: Int, height: Int, attributes: Map = emptyMap()) { + testImagePath = File.createTempFile("test", ".jpg").absolutePath + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + ImageFileUtils.saveBitmapToFile(bitmap, testImagePath) + val exifInterface = ExifInterface(testImagePath) + for ((key, value) in attributes) { + exifInterface.setAttribute(key, value) + } + exifInterface.saveAttributes() + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/utilities/ImageFileUtilsTest.kt b/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageFileUtilsTest.kt similarity index 92% rename from collect_app/src/androidTest/java/org/odk/collect/android/instrumented/utilities/ImageFileUtilsTest.kt rename to androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageFileUtilsTest.kt index 31b64f74778..749f5bea83a 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/utilities/ImageFileUtilsTest.kt +++ b/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageFileUtilsTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.odk.collect.android.instrumented.utilities +package org.odk.collect.androidshared.bitmap import android.content.Context import android.graphics.Bitmap @@ -26,7 +26,6 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.odk.collect.android.utilities.ImageFileUtils import timber.log.Timber import java.io.File import java.io.IOException @@ -50,7 +49,10 @@ class ImageFileUtilsTest { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_90.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) - val image = ImageFileUtils.getBitmap(destinationFile.absolutePath, BitmapFactory.Options())!! + val image = ImageFileUtils.getBitmap( + destinationFile.absolutePath, + BitmapFactory.Options() + )!! assertEquals(2, image.width) assertEquals(1, image.height) @@ -64,7 +66,10 @@ class ImageFileUtilsTest { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_270.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) - val image = ImageFileUtils.getBitmap(destinationFile.absolutePath, BitmapFactory.Options())!! + val image = ImageFileUtils.getBitmap( + destinationFile.absolutePath, + BitmapFactory.Options() + )!! assertEquals(2, image.width) assertEquals(1, image.height) @@ -78,7 +83,10 @@ class ImageFileUtilsTest { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_180.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) - val image = ImageFileUtils.getBitmap(destinationFile.absolutePath, BitmapFactory.Options())!! + val image = ImageFileUtils.getBitmap( + destinationFile.absolutePath, + BitmapFactory.Options() + )!! assertEquals(1, image.width) assertEquals(2, image.height) @@ -92,7 +100,10 @@ class ImageFileUtilsTest { attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_UNDEFINED.toString() saveTestBitmapToFile(sourceFile.absolutePath, attributes) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) - val image = ImageFileUtils.getBitmap(destinationFile.absolutePath, BitmapFactory.Options())!! + val image = ImageFileUtils.getBitmap( + destinationFile.absolutePath, + BitmapFactory.Options() + )!! assertEquals(1, image.width) assertEquals(2, image.height) @@ -105,7 +116,10 @@ class ImageFileUtilsTest { fun copyAndRotateImageNoExif() { saveTestBitmapToFile(sourceFile.absolutePath, null) ImageFileUtils.copyImageAndApplyExifRotation(sourceFile, destinationFile) - val image = ImageFileUtils.getBitmap(destinationFile.absolutePath, BitmapFactory.Options())!! + val image = ImageFileUtils.getBitmap( + destinationFile.absolutePath, + BitmapFactory.Options() + )!! assertEquals(1, image.width) assertEquals(2, image.height) diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageCompressor.kt b/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageCompressor.kt new file mode 100644 index 00000000000..e5e94b283b9 --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageCompressor.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2017 Nafundi + * + * 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.odk.collect.androidshared.bitmap + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface +import timber.log.Timber + +object ImageCompressor { + /** + * Before proceed with scaling or rotating, make sure existing exif information is stored/restored. + * @author Khuong Ninh (khuong.ninh@it-development.com) + */ + fun execute(imagePath: String, maxPixels: Int) { + backupExifData(imagePath) + scaleDownImage(imagePath, maxPixels) + restoreExifData(imagePath) + } + + /** + * This method is used to reduce an original picture size. + * maxPixels refers to the max pixels of the long edge, the short edge is scaled proportionately. + */ + private fun scaleDownImage(imagePath: String, maxPixels: Int) { + if (maxPixels <= 0) { + return + } + + var image = ImageFileUtils.getBitmap(imagePath, BitmapFactory.Options()) + if (image != null) { + val originalWidth = image.width.toDouble() + val originalHeight = image.height.toDouble() + if (originalWidth > originalHeight && originalWidth > maxPixels) { + val newHeight = (originalHeight / (originalWidth / maxPixels)).toInt() + image = Bitmap.createScaledBitmap(image, maxPixels, newHeight, false) + ImageFileUtils.saveBitmapToFile(image, imagePath) + } else if (originalHeight > maxPixels) { + val newWidth = (originalWidth / (originalHeight / maxPixels)).toInt() + image = Bitmap.createScaledBitmap(image, newWidth, maxPixels, false) + ImageFileUtils.saveBitmapToFile(image, imagePath) + } + } + } + + private fun backupExifData(imagePath: String) { + try { + val exif = ExifInterface(imagePath) + for ((key, _) in exifDataBackup) { + exifDataBackup[key] = exif.getAttribute(key) + } + } catch (e: Throwable) { + Timber.w(e) + } + } + + private fun restoreExifData(imagePath: String) { + try { + val exif = ExifInterface(imagePath) + for ((key, value) in exifDataBackup) { + exif.setAttribute(key, value) + } + exif.saveAttributes() + } catch (e: Throwable) { + Timber.w(e) + } + } + + private val exifDataBackup = mutableMapOf( + ExifInterface.TAG_DATETIME to null, + ExifInterface.TAG_DATETIME_ORIGINAL to null, + ExifInterface.TAG_DATETIME_DIGITIZED to null, + ExifInterface.TAG_OFFSET_TIME to null, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL to null, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED to null, + ExifInterface.TAG_SUBSEC_TIME to null, + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to null, + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to null, + ExifInterface.TAG_IMAGE_DESCRIPTION to null, + ExifInterface.TAG_MAKE to null, + ExifInterface.TAG_MODEL to null, + ExifInterface.TAG_SOFTWARE to null, + ExifInterface.TAG_ARTIST to null, + ExifInterface.TAG_COPYRIGHT to null, + ExifInterface.TAG_MAKER_NOTE to null, + ExifInterface.TAG_USER_COMMENT to null, + ExifInterface.TAG_IMAGE_UNIQUE_ID to null, + ExifInterface.TAG_CAMERA_OWNER_NAME to null, + ExifInterface.TAG_BODY_SERIAL_NUMBER to null, + ExifInterface.TAG_GPS_ALTITUDE to null, + ExifInterface.TAG_GPS_ALTITUDE_REF to null, + ExifInterface.TAG_GPS_DATESTAMP to null, + ExifInterface.TAG_GPS_TIMESTAMP to null, + ExifInterface.TAG_GPS_LATITUDE to null, + ExifInterface.TAG_GPS_LATITUDE_REF to null, + ExifInterface.TAG_GPS_LONGITUDE to null, + ExifInterface.TAG_GPS_LONGITUDE_REF to null, + ExifInterface.TAG_GPS_SATELLITES to null, + ExifInterface.TAG_GPS_STATUS to null, + ExifInterface.TAG_ORIENTATION to null + ) +} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageFileUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt similarity index 98% rename from collect_app/src/main/java/org/odk/collect/android/utilities/ImageFileUtils.kt rename to androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt index 753e94cef6e..fec44ca3478 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageFileUtils.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.utilities +package org.odk.collect.androidshared.bitmap import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat @@ -150,7 +150,7 @@ object ImageFileUtils { ) ) { // Source Image doesn't have any EXIF Rotations, so a normal file copy will suffice - FileUtils.copyFile(sourceFile, destFile) + sourceFile.copyTo(destFile, true) } else { val sourceImage = getBitmap(sourceFile.absolutePath, BitmapFactory.Options()) val orientation = sourceFileExif.getAttributeInt( diff --git a/buildSrc/src/main/java/dependencies/Dependencies.kt b/buildSrc/src/main/java/dependencies/Dependencies.kt index d5fb02c7909..bc34ce13a45 100644 --- a/buildSrc/src/main/java/dependencies/Dependencies.kt +++ b/buildSrc/src/main/java/dependencies/Dependencies.kt @@ -17,7 +17,7 @@ object Dependencies { const val androidx_appcompat = "androidx.appcompat:appcompat:1.5.1" const val androidx_work_runtime = "androidx.work:work-runtime:${Versions.work}" const val androidx_cardview = "androidx.cardview:cardview:1.0.0" - const val androidx_exinterface = "androidx.exifinterface:exifinterface:1.3.1" // Check if https://github.com/getodk/collect/issues/4819 and https://github.com/getodk/collect/issues/5033 no longer takes place before upgrading + const val androidx_exinterface = "androidx.exifinterface:exifinterface:1.3.6" const val androidx_multidex = "androidx.multidex:multidex:2.0.1" const val androidx_preference_ktx = "androidx.preference:preference-ktx:1.2.0" const val androidx_fragment_ktx = "androidx.fragment:fragment-ktx:${Versions.androidx_fragment}" diff --git a/collect_app/build.gradle b/collect_app/build.gradle index 2522fc82ce1..a295ab8fcc8 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -283,7 +283,6 @@ dependencies { implementation Dependencies.androidx_work_runtime implementation Dependencies.androidx_cardview - implementation Dependencies.androidx_exinterface implementation Dependencies.androidx_multidex implementation Dependencies.androidx_preference_ktx implementation Dependencies.androidx_fragment_ktx diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/utilities/ImageConverterTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/utilities/ImageConverterTest.kt deleted file mode 100644 index 10617bd61c6..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/utilities/ImageConverterTest.kt +++ /dev/null @@ -1,460 +0,0 @@ -/* - * Copyright 2017 Nafundi - * - * 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.odk.collect.android.instrumented.utilities - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.ExifInterface -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.hamcrest.CoreMatchers.`is` -import org.hamcrest.MatcherAssert.assertThat -import org.javarosa.core.model.instance.TreeElement -import org.javarosa.form.api.FormEntryPrompt -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock -import org.odk.collect.android.TestSettingsProvider.getUnprotectedSettings -import org.odk.collect.android.injection.DaggerUtils -import org.odk.collect.android.storage.StoragePathProvider -import org.odk.collect.android.storage.StorageSubdirectory -import org.odk.collect.android.support.rules.RunnableRule -import org.odk.collect.android.support.rules.TestRuleChain -import org.odk.collect.android.utilities.ApplicationConstants.Namespaces -import org.odk.collect.android.utilities.ImageConverter -import org.odk.collect.android.utilities.ImageFileUtils -import org.odk.collect.android.widgets.ImageWidget -import org.odk.collect.projects.Project -import timber.log.Timber -import java.io.File -import java.io.IOException -import java.lang.Exception -import java.util.HashMap - -@RunWith(AndroidJUnit4::class) -class ImageConverterTest { - private lateinit var testImagePath: String - private val generalSettings = getUnprotectedSettings() - private val context = ApplicationProvider.getApplicationContext() - - @get:Rule - var copyFormChain: RuleChain = TestRuleChain.chain() - .around( - RunnableRule { - // Set up demo project - val component = DaggerUtils.getComponent(ApplicationProvider.getApplicationContext()) - component.projectsRepository().save(Project.DEMO_PROJECT) - component.currentProjectProvider().setCurrentProject(Project.DEMO_PROJECT_ID) - } - ) - - @Before - fun setUp() { - testImagePath = - StoragePathProvider().getOdkDirPath(StorageSubdirectory.INSTANCES) + File.separator + "testForm_2017-10-12_19-36-15" + File.separator + "testImage.jpg" - File(testImagePath).parentFile.mkdirs() - } - - @Test - fun executeConversionWithoutAnySettings() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_ORIGINAL) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly1() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(4000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "2000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(2000, image!!.width) - assertEquals(1500, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly2() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 4000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "2000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(1500, image!!.width) - assertEquals(2000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly3() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "2000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(2000, image!!.width) - assertEquals(2000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly4() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "3000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly5() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "4000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly6() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "2998"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(2998, image!!.width) - assertEquals(2998, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly7() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", ""), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly8() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget("", "max-pixels", "2000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly9() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixel", "2000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly10() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "2000.5"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly11() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "0"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownFormLevelOnly12() { - generalSettings.save("image_size", "original_image_size") - saveTestBitmap(3000, 3000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "-2000"), - context, - IMAGE_SIZE_ORIGINAL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownSettingsLevelOnly1() { - generalSettings.save("image_size", IMAGE_SIZE_VERY_SMALL) - saveTestBitmap(3000, 3000) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_VERY_SMALL) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(640, image!!.width) - assertEquals(640, image.height) - } - - @Test - fun scaleImageDownSettingsLevelOnly2() { - generalSettings.save("image_size", IMAGE_SIZE_SMALL) - saveTestBitmap(3000, 3000) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_SMALL) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(1024, image!!.width) - assertEquals(1024, image.height) - } - - @Test - fun scaleImageDownSettingsLevelOnly3() { - generalSettings.save("image_size", IMAGE_SIZE_MEDIUM) - saveTestBitmap(3000, 3000) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_MEDIUM) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(2048, image!!.width) - assertEquals(2048, image.height) - } - - @Test - fun scaleImageDownSettingsLevelOnly4() { - generalSettings.save("image_size", IMAGE_SIZE_LARGE) - saveTestBitmap(3000, 3000) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_LARGE) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(3000, image.height) - } - - @Test - fun scaleImageDownSettingsLevelOnly5() { - generalSettings.save("image_size", IMAGE_SIZE_LARGE) - saveTestBitmap(4000, 4000) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_LARGE) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3072, image!!.width) - assertEquals(3072, image.height) - } - - @Test - fun scaleImageDownFormAndSettingsLevel1() { - generalSettings.save("image_size", IMAGE_SIZE_SMALL) - saveTestBitmap(4000, 4000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "2000"), - context, - IMAGE_SIZE_SMALL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(2000, image!!.width) - assertEquals(2000, image.height) - } - - @Test - fun scaleImageDownFormAndSettingsLevel2() { - generalSettings.save("image_size", "small") - saveTestBitmap(4000, 4000) - ImageConverter.execute( - testImagePath, - getTestImageWidget(Namespaces.XML_OPENROSA_NAMESPACE, "max-pixels", "650"), - context, - IMAGE_SIZE_SMALL - ) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(650, image!!.width) - assertEquals(650, image.height) - } - - @Test - fun keepExifAfterScaling() { - val attributes: MutableMap = HashMap() - attributes[ExifInterface.TAG_ARTIST] = ExifInterface.TAG_ARTIST - attributes[ExifInterface.TAG_DATETIME] = ExifInterface.TAG_DATETIME - attributes[ExifInterface.TAG_DATETIME_ORIGINAL] = ExifInterface.TAG_DATETIME_ORIGINAL - attributes[ExifInterface.TAG_DATETIME_DIGITIZED] = ExifInterface.TAG_DATETIME_DIGITIZED - attributes[ExifInterface.TAG_GPS_ALTITUDE] = dec2DMS(-17.0) - attributes[ExifInterface.TAG_GPS_ALTITUDE_REF] = ExifInterface.TAG_GPS_ALTITUDE_REF - attributes[ExifInterface.TAG_GPS_DATESTAMP] = ExifInterface.TAG_GPS_DATESTAMP - attributes[ExifInterface.TAG_GPS_LATITUDE] = dec2DMS(25.165173) - attributes[ExifInterface.TAG_GPS_LATITUDE_REF] = ExifInterface.TAG_GPS_LATITUDE_REF - attributes[ExifInterface.TAG_GPS_LONGITUDE] = dec2DMS(23.988174) - attributes[ExifInterface.TAG_GPS_LONGITUDE_REF] = ExifInterface.TAG_GPS_LONGITUDE_REF - attributes[ExifInterface.TAG_GPS_PROCESSING_METHOD] = ExifInterface.TAG_GPS_PROCESSING_METHOD - attributes[ExifInterface.TAG_MAKE] = ExifInterface.TAG_MAKE - attributes[ExifInterface.TAG_MODEL] = ExifInterface.TAG_MODEL - attributes[ExifInterface.TAG_SUBSEC_TIME] = ExifInterface.TAG_SUBSEC_TIME - - saveTestBitmap(3000, 4000, attributes) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_ORIGINAL) - val exifData = testImageExif - assertNotNull(exifData) - - for (attributeName in attributes.keys) { - when (attributeName) { - ExifInterface.TAG_GPS_LATITUDE -> assertThat( - exifData!!.getAttribute(attributeName), `is`("25/1,9/1,54622/1000") - ) - ExifInterface.TAG_GPS_LONGITUDE -> assertThat( - exifData!!.getAttribute(attributeName), `is`("23/1,59/1,17426/1000") - ) - ExifInterface.TAG_GPS_ALTITUDE -> assertThat( - exifData!!.getAttribute(attributeName), `is`("17/1,0/1,0/1000") - ) - else -> assertThat(exifData!!.getAttribute(attributeName), `is`(attributeName)) - } - } - } - - @Test - fun verifyNoRotationAppliedForExifRotation() { - val attributes: MutableMap = HashMap() - attributes[ExifInterface.TAG_ORIENTATION] = ExifInterface.ORIENTATION_ROTATE_90.toString() - saveTestBitmap(3000, 4000, attributes) - ImageConverter.execute(testImagePath, getTestImageWidget(), context, IMAGE_SIZE_ORIGINAL) - val image = ImageFileUtils.getBitmap(testImagePath, BitmapFactory.Options()) - assertEquals(3000, image!!.width) - assertEquals(4000, image.height) - } - - // https://stackoverflow.com/a/55252228/5479029 - private fun dec2DMS(coord: Double): String { - var coord = coord - coord = if (coord > 0) coord else -coord - var out = "${coord.toInt()}/1," - coord = coord % 1 * 60 - out = "${out + coord.toInt()}/1," - coord = coord % 1 * 60000 - out = "${out + coord.toInt()}/1000" - return out - } - - private fun saveTestBitmap( - width: Int, - height: Int, - attributes: Map = HashMap() - ) { - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) - ImageFileUtils.saveBitmapToFile(bitmap, testImagePath) - try { - val exifInterface = ExifInterface(testImagePath) - for (attributeName in attributes.keys) { - exifInterface.setAttribute(attributeName, attributes[attributeName]) - } - exifInterface.saveAttributes() - } catch (e: IOException) { - Timber.w(e) - } - } - - private val testImageExif: ExifInterface? - get() { - try { - return ExifInterface(testImagePath) - } catch (e: Exception) { - Timber.w(e) - } - return null - } - - private fun getTestImageWidget(namespace: String, name: String, value: String): ImageWidget { - val bindAttributes: MutableList = mutableListOf() - bindAttributes.add(TreeElement.constructAttributeElement(namespace, name, value)) - return getTestImageWidget(bindAttributes) - } - - private fun getTestImageWidget(bindAttributes: List = emptyList()): ImageWidget { - val formEntryPrompt = mock(FormEntryPrompt::class.java) - `when`(formEntryPrompt.bindAttributes).thenReturn(bindAttributes) - val imageWidget = mock(ImageWidget::class.java) - `when`(imageWidget.formEntryPrompt).thenReturn(formEntryPrompt) - return imageWidget - } - - companion object { - private const val IMAGE_SIZE_ORIGINAL = "original_image_size" - private const val IMAGE_SIZE_LARGE = "large" - private const val IMAGE_SIZE_MEDIUM = "medium" - private const val IMAGE_SIZE_SMALL = "small" - private const val IMAGE_SIZE_VERY_SMALL = "very_small" - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/configure/qr/CachingQRCodeGenerator.kt b/collect_app/src/main/java/org/odk/collect/android/configure/qr/CachingQRCodeGenerator.kt index 135b6b33be5..090a549fc3e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/configure/qr/CachingQRCodeGenerator.kt +++ b/collect_app/src/main/java/org/odk/collect/android/configure/qr/CachingQRCodeGenerator.kt @@ -5,7 +5,7 @@ import org.json.JSONException import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.storage.StorageSubdirectory import org.odk.collect.android.utilities.FileUtils -import org.odk.collect.android.utilities.ImageFileUtils.saveBitmapToFile +import org.odk.collect.androidshared.bitmap.ImageFileUtils import org.odk.collect.qrcode.QRCodeDecoder import org.odk.collect.qrcode.QRCodeEncoder import timber.log.Timber @@ -62,7 +62,7 @@ class CachingQRCodeGenerator(private val qrCodeEncoder: QRCodeEncoder) : QRCodeG val bmp = qrCodeEncoder.encode(preferencesString) Timber.i("QR Code generation took : %d ms", System.currentTimeMillis() - time) Timber.i("Saving QR Code to disk... : %s", qRCodeFilepath) - saveBitmapToFile(bmp, qRCodeFilepath) + ImageFileUtils.saveBitmapToFile(bmp, qRCodeFilepath) FileUtils.write(mdCacheFile, messageDigest) Timber.i("Updated %s file contents", SETTINGS_MD5_FILE) } diff --git a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeViewModel.kt index f94fbfd61c8..3d903cc430f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeViewModel.kt @@ -8,7 +8,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.odk.collect.android.R -import org.odk.collect.android.utilities.ImageFileUtils.getBitmap +import org.odk.collect.androidshared.bitmap.ImageFileUtils import org.odk.collect.async.Scheduler import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProjectKeys @@ -50,7 +50,7 @@ class QRCodeViewModel( qrCodeGenerator.generateQRCode(includedKeys, appConfigurationGenerator) val options = BitmapFactory.Options() options.inPreferredConfig = Bitmap.Config.ARGB_8888 - val bitmap = getBitmap(filePath, options) + val bitmap = ImageFileUtils.getBitmap(filePath, options) return@immediate Pair(filePath, bitmap) } catch (ignored: Exception) { // Ignored diff --git a/collect_app/src/main/java/org/odk/collect/android/draw/DrawActivity.java b/collect_app/src/main/java/org/odk/collect/android/draw/DrawActivity.java index ad090462991..f6457163eaf 100644 --- a/collect_app/src/main/java/org/odk/collect/android/draw/DrawActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/draw/DrawActivity.java @@ -46,7 +46,7 @@ import org.odk.collect.android.utilities.AnimationUtils; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.utilities.DialogUtils; -import org.odk.collect.android.utilities.ImageFileUtils; +import org.odk.collect.androidshared.bitmap.ImageFileUtils; import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.strings.localization.LocalizedActivity; diff --git a/collect_app/src/main/java/org/odk/collect/android/draw/DrawView.kt b/collect_app/src/main/java/org/odk/collect/android/draw/DrawView.kt index 607ed48d376..5d28f7e9c02 100644 --- a/collect_app/src/main/java/org/odk/collect/android/draw/DrawView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/draw/DrawView.kt @@ -25,7 +25,7 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import org.odk.collect.android.storage.StoragePathProvider -import org.odk.collect.android.utilities.ImageFileUtils +import org.odk.collect.androidshared.bitmap.ImageFileUtils import java.io.File class DrawView(context: Context?, attrs: AttributeSet?) : View(context, attrs) { diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index c8f1d228b07..8ed6f581fd5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -97,6 +97,8 @@ import org.odk.collect.android.utilities.FileProvider; import org.odk.collect.android.utilities.FormsDirDiskFormsSynchronizer; import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.androidshared.bitmap.ImageCompressor; +import org.odk.collect.android.utilities.ImageCompressionController; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.ProjectResetter; @@ -636,4 +638,10 @@ public ProjectDependencyProviderFactory providesProjectDependencyProviderFactory public BlankFormListViewModel.Factory providesBlankFormListViewModel(FormsRepositoryProvider formsRepositoryProvider, InstancesRepositoryProvider instancesRepositoryProvider, Application application, SyncStatusAppState syncStatusAppState, FormsUpdater formsUpdater, Scheduler scheduler, SettingsProvider settingsProvider, ChangeLockProvider changeLockProvider, CurrentProjectProvider currentProjectProvider) { return new BlankFormListViewModel.Factory(formsRepositoryProvider.get(), instancesRepositoryProvider.get(), application, syncStatusAppState, formsUpdater, scheduler, settingsProvider.getUnprotectedSettings(), changeLockProvider, new FormsDirDiskFormsSynchronizer(), currentProjectProvider.getCurrentProject().getUuid()); } + + @Provides + @Singleton + public ImageCompressionController providesImageCompressorManager() { + return new ImageCompressionController(ImageCompressor.INSTANCE); + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/MediaLoadingTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/MediaLoadingTask.java index 96b578aea56..03cc1357f71 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/MediaLoadingTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/MediaLoadingTask.java @@ -10,7 +10,7 @@ import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.FileUtils; -import org.odk.collect.android.utilities.ImageConverter; +import org.odk.collect.android.utilities.ImageCompressionController; import org.odk.collect.android.widgets.BaseImageWidget; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.androidshared.ui.DialogFragmentUtils; @@ -27,6 +27,9 @@ public class MediaLoadingTask extends AsyncTask { @Inject SettingsProvider settingsProvider; + @Inject + ImageCompressionController imageCompressionController; + private WeakReference formEntryActivity; public MediaLoadingTask(FormEntryActivity formEntryActivity, File instanceFile) { @@ -51,7 +54,7 @@ protected File doInBackground(Uri... uris) { // apply image conversion if the widget is an image widget if (questionWidget instanceof BaseImageWidget) { String imageSizeMode = settingsProvider.getUnprotectedSettings().getString(KEY_IMAGE_SIZE); - ImageConverter.execute(newFile.getPath(), questionWidget, formEntryActivity.get(), imageSizeMode); + imageCompressionController.execute(newFile.getPath(), questionWidget, formEntryActivity.get(), imageSizeMode); } return newFile; } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt new file mode 100644 index 00000000000..a4953c82e68 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt @@ -0,0 +1,49 @@ +package org.odk.collect.android.utilities + +import android.content.Context +import org.odk.collect.android.R +import org.odk.collect.android.widgets.QuestionWidget +import org.odk.collect.androidshared.bitmap.ImageCompressor +import timber.log.Timber + +class ImageCompressionController(private val imageCompressor: ImageCompressor) { + fun execute( + imagePath: String, + questionWidget: QuestionWidget, + context: Context, + imageSizeMode: String + ) { + var maxPixels: Int? + maxPixels = getMaxPixelsFromFormIfDefined(questionWidget) + if (maxPixels == null) { + maxPixels = getMaxPixelsFromSettings(context, imageSizeMode) + } + if (maxPixels != null && maxPixels > 0) { + imageCompressor.execute(imagePath, maxPixels) + } + } + + private fun getMaxPixelsFromFormIfDefined(questionWidget: QuestionWidget): Int? { + for (bindAttribute in questionWidget.formEntryPrompt.bindAttributes) { + if ("max-pixels" == bindAttribute.name && ApplicationConstants.Namespaces.XML_OPENROSA_NAMESPACE == bindAttribute.namespace) { + try { + return bindAttribute.attributeValue.toInt() + } catch (e: NumberFormatException) { + Timber.i(e) + } + } + } + return null + } + + private fun getMaxPixelsFromSettings(context: Context, imageSizeMode: String): Int? { + val imageEntryValues = context.resources.getStringArray(R.array.image_size_entry_values) + return when (imageSizeMode) { + imageEntryValues[1] -> 640 + imageEntryValues[2] -> 1024 + imageEntryValues[3] -> 2048 + imageEntryValues[4] -> 3072 + else -> null + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageConverter.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ImageConverter.kt deleted file mode 100644 index 751b552bd85..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageConverter.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2017 Nafundi - * - * 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.odk.collect.android.utilities - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import androidx.exifinterface.media.ExifInterface -import org.odk.collect.android.R -import org.odk.collect.android.widgets.QuestionWidget -import timber.log.Timber -import java.io.IOException - -object ImageConverter { - /** - * Before proceed with scaling or rotating, make sure existing exif information is stored/restored. - * @author Khuong Ninh (khuong.ninh@it-development.com) - */ - @JvmStatic - fun execute( - imagePath: String, - questionWidget: QuestionWidget, - context: Context, - imageSizeMode: String - ) { - var exif: ExifInterface? = null - try { - exif = ExifInterface(imagePath) - } catch (e: IOException) { - Timber.w(e) - } - scaleDownImageIfNeeded(imagePath, questionWidget, context, imageSizeMode) - if (exif != null) { - try { - exif.saveAttributes() - } catch (e: IOException) { - Timber.w(e) - } - } - } - - private fun scaleDownImageIfNeeded( - imagePath: String, - questionWidget: QuestionWidget, - context: Context, - imageSizeMode: String - ) { - var maxPixels: Int? - maxPixels = getMaxPixelsFromFormIfDefined(questionWidget) - if (maxPixels == null) { - maxPixels = getMaxPixelsFromSettings(context, imageSizeMode) - } - if (maxPixels != null && maxPixels > 0) { - scaleDownImage(imagePath, maxPixels) - } - } - - private fun getMaxPixelsFromFormIfDefined(questionWidget: QuestionWidget): Int? { - for (attrs in questionWidget.formEntryPrompt.bindAttributes) { - if ("max-pixels" == attrs.name && ApplicationConstants.Namespaces.XML_OPENROSA_NAMESPACE == attrs.namespace) { - try { - return attrs.attributeValue.toInt() - } catch (e: NumberFormatException) { - Timber.i(e) - } - } - } - return null - } - - private fun getMaxPixelsFromSettings(context: Context, imageSizeMode: String): Int? { - val imageEntryValues = context.resources.getStringArray(R.array.image_size_entry_values) - return when (imageSizeMode) { - imageEntryValues[1] -> 640 - imageEntryValues[2] -> 1024 - imageEntryValues[3] -> 2048 - imageEntryValues[4] -> 3072 - else -> null - } - } - - /** - * This method is used to reduce an original picture size. - * maxPixels refers to the max pixels of the long edge, the short edge is scaled proportionately. - */ - private fun scaleDownImage(imagePath: String, maxPixels: Int) { - var image = ImageFileUtils.getBitmap(imagePath, BitmapFactory.Options()) - if (image != null) { - val originalWidth = image.width.toDouble() - val originalHeight = image.height.toDouble() - if (originalWidth > originalHeight && originalWidth > maxPixels) { - val newHeight = (originalHeight / (originalWidth / maxPixels)).toInt() - image = Bitmap.createScaledBitmap(image, maxPixels, newHeight, false) - ImageFileUtils.saveBitmapToFile(image, imagePath) - } else if (originalHeight > maxPixels) { - val newWidth = (originalWidth / (originalHeight / maxPixels)).toInt() - image = Bitmap.createScaledBitmap(image, newWidth, maxPixels, false) - ImageFileUtils.saveBitmapToFile(image, imagePath) - } - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/LabelWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/items/LabelWidget.java index c1d2aa2bf1d..b230ba4f777 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/LabelWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/LabelWidget.java @@ -35,7 +35,7 @@ import org.odk.collect.android.externaldata.ExternalSelectChoice; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.HtmlUtils; -import org.odk.collect.android.utilities.ImageFileUtils; +import org.odk.collect.androidshared.bitmap.ImageFileUtils; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.android.widgets.interfaces.SelectChoiceLoader; import org.odk.collect.android.widgets.warnings.SpacesInUnderlyingValuesWarning; diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java index 7b16ffcc4ba..a3e999ad926 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java @@ -32,7 +32,7 @@ import org.odk.collect.android.externaldata.ExternalSelectChoice; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.HtmlUtils; -import org.odk.collect.android.utilities.ImageFileUtils; +import org.odk.collect.androidshared.bitmap.ImageFileUtils; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.android.widgets.interfaces.SelectChoiceLoader; diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListMultiWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListMultiWidget.java index 900a12aefcd..feacec0d651 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListMultiWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListMultiWidget.java @@ -42,7 +42,7 @@ import org.odk.collect.android.externaldata.ExternalSelectChoice; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.HtmlUtils; -import org.odk.collect.android.utilities.ImageFileUtils; +import org.odk.collect.androidshared.bitmap.ImageFileUtils; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.android.widgets.interfaces.MultiChoiceWidget; import org.odk.collect.android.widgets.interfaces.SelectChoiceLoader; diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListWidget.java index b51f453dfe7..f266baef14f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/ListWidget.java @@ -44,7 +44,7 @@ import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.listeners.AdvanceToNextListener; import org.odk.collect.android.utilities.HtmlUtils; -import org.odk.collect.android.utilities.ImageFileUtils; +import org.odk.collect.androidshared.bitmap.ImageFileUtils; import org.odk.collect.android.utilities.SelectOneWidgetUtils; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.android.widgets.interfaces.MultiChoiceWidget; diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/ImageCompressionControllerTest.kt b/collect_app/src/test/java/org/odk/collect/android/utilities/ImageCompressionControllerTest.kt new file mode 100644 index 00000000000..a285767fa30 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/ImageCompressionControllerTest.kt @@ -0,0 +1,172 @@ +package org.odk.collect.android.utilities + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.javarosa.core.model.instance.TreeElement +import org.javarosa.form.api.FormEntryPrompt +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.odk.collect.android.widgets.QuestionWidget +import org.odk.collect.androidshared.bitmap.ImageCompressor + +@RunWith(AndroidJUnit4::class) +class ImageCompressionControllerTest { + + private val context = ApplicationProvider.getApplicationContext() + private val formEntryPrompt = mock() + private val questionWidget = mock().also { + whenever(it.formEntryPrompt).thenReturn(formEntryPrompt) + } + private val treeElement = mock().also { + whenever(it.attributeValue).thenReturn("123") + whenever(it.name).thenReturn("max-pixels") + whenever(it.namespace).thenReturn(ApplicationConstants.Namespaces.XML_OPENROSA_NAMESPACE) + } + private val imageCompressor = mock() + private val imageCompressionController = ImageCompressionController(imageCompressor) + + @Test + fun `when 'max-pixels' is not specified on a form level and expected image size in setting is 'original_image_size', image compression should not be triggered`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(emptyList()) + + imageCompressionController.execute("/path", questionWidget, context, "original_image_size") + + verifyNoInteractions(imageCompressor) + } + + @Test + fun `when 'max-pixels' is not specified on a form level and expected image size in setting is 'very_small', image compression should be triggered for 640px`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(emptyList()) + + imageCompressionController.execute("/path", questionWidget, context, "very_small") + + verify(imageCompressor).execute("/path", 640) + } + + @Test + fun `when 'max-pixels' is not specified on a form level and expected image size in setting is 'small', image compression should be triggered for 1024px`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(emptyList()) + + imageCompressionController.execute("/path", questionWidget, context, "small") + + verify(imageCompressor).execute("/path", 1024) + } + + @Test + fun `when 'max-pixels' is not specified on a form level and expected image size in setting is 'medium', image compression should be triggered for 2048px`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(emptyList()) + + imageCompressionController.execute("/path", questionWidget, context, "medium") + + verify(imageCompressor).execute("/path", 2048) + } + + @Test + fun `when 'max-pixels' is not specified on a form level and expected image size in setting is 'large', image compression should be triggered for 3072px`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(emptyList()) + + imageCompressionController.execute("/path", questionWidget, context, "large") + + verify(imageCompressor).execute("/path", 3072) + } + + @Test + fun `when 'max-pixels' is specified on a form level and expected image size in setting is 'original_image_size', image compression should be triggered for value stored in 'max-pixels'`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "original_image_size") + + verify(imageCompressor).execute("/path", 123) + } + + @Test + fun `when 'max-pixels' is specified on a form level and expected image size in setting is 'very_small', image compression should be triggered and the value stored in for value stored in 'max-pixels' should take precedence`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "very_small") + + verify(imageCompressor).execute("/path", 123) + } + + @Test + fun `when 'max-pixels' is specified on a form level and expected image size in setting is 'small', image compression should be triggered and the value stored in for value stored in 'max-pixels' should take precedence`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "small") + + verify(imageCompressor).execute("/path", 123) + } + + @Test + fun `when 'max-pixels' is specified on a form level and expected image size in setting is 'medium', image compression should be triggered and the value stored in for value stored in 'max-pixels' should take precedence`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "medium") + + verify(imageCompressor).execute("/path", 123) + } + + @Test + fun `when 'max-pixels' is specified on a form level and expected image size in setting is 'large', image compression should be triggered and the value stored in for value stored in 'max-pixels' should take precedence`() { + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "large") + + verify(imageCompressor).execute("/path", 123) + } + + @Test + fun `when 'max-pixels' is specified on a form level but it is not a valid integer value and expected image size in setting is 'original_image_size', image compression should not be triggered`() { + whenever(treeElement.attributeValue).thenReturn("123.5") + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "original_image_size") + + verifyNoInteractions(imageCompressor) + } + + @Test + fun `when 'max-pixels' is specified on a form level but it is not a valid integer value and expected image size in setting is 'very_small', image compression should be triggered for 640px`() { + whenever(treeElement.attributeValue).thenReturn("123.5") + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "very_small") + + verify(imageCompressor).execute("/path", 640) + } + + @Test + fun `when 'max-pixels' is specified on a form level but it is not a valid integer value and expected image size in setting is 'small', image compression should be triggered for 1024px`() { + whenever(treeElement.attributeValue).thenReturn("123.5") + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "small") + + verify(imageCompressor).execute("/path", 1024) + } + + @Test + fun `when 'max-pixels' is specified on a form level but it is not a valid integer value and expected image size in setting is 'medium', image compression should be triggered for 2048px`() { + whenever(treeElement.attributeValue).thenReturn("123.5") + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "medium") + + verify(imageCompressor).execute("/path", 2048) + } + + @Test + fun `when 'max-pixels' is specified on a form level but it is not a valid integer value and expected image size in setting is 'large', image compression should be triggered for 3072px`() { + whenever(treeElement.attributeValue).thenReturn("123.5") + whenever(formEntryPrompt.bindAttributes).thenReturn(listOf(treeElement)) + + imageCompressionController.execute("/path", questionWidget, context, "large") + + verify(imageCompressor).execute("/path", 3072) + } +}