Skip to content

WIP: Adds 'orientation' field to Result #236

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import java.io.IOException
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.ColorPropConverter
Expand Down Expand Up @@ -163,7 +166,13 @@ class MultipleImagePickerImp(reactContext: ReactApplicationContext?) :
localMedia.forEach { item ->
if (item != null) {
val media = getResult(item)
data += media // Add the media to the data array
// Adjust orientation using ExifInterface for Android (only for images)
val adjustedMedia = if (media.type == ResultType.IMAGE && !item.realPath.isNullOrBlank()) {
adjustOrientation(media, item.realPath)
} else {
media
}
data += adjustedMedia // Add the media to the data array
}
}
resolved(data)
Expand Down Expand Up @@ -634,6 +643,7 @@ class MultipleImagePickerImp(reactContext: ReactApplicationContext?) :
parentFolderName = item.parentFolderName,
creationDate = item.dateAddedTime.toDouble(),
crop = item.isCut,
orientation = null, // Will be populated by adjustOrientation for images
path,
type,
fileName = item.fileName,
Expand All @@ -644,6 +654,114 @@ class MultipleImagePickerImp(reactContext: ReactApplicationContext?) :
return media
}

private fun adjustOrientation(pickerResult: PickerResult, filePath: String): PickerResult {
// Validate file path
if (filePath.isBlank()) {
Log.w(TAG, "File path is blank, returning original result")
return pickerResult
}

// Clean the file path - remove file:// prefix and handle content URIs
val cleanPath = when {
filePath.startsWith("file://") -> filePath.removePrefix("file://")
filePath.startsWith("content://") -> {
Log.w(TAG, "Content URI provided instead of file path: $filePath, returning original result")
return pickerResult
}
else -> filePath
}

val file = File(cleanPath)
Log.d(TAG, "Checking orientation for file: ${file.absolutePath}")

if (!file.exists()) {
Log.w(TAG, "File does not exist: ${file.absolutePath} (original path: $filePath)")
return pickerResult
}

if (!file.canRead()) {
Log.w(TAG, "Cannot read file: ${file.absolutePath}")
return pickerResult
}

try {
val ei = ExifInterface(file.absolutePath)
val orientation = ei.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED
)

Log.d(TAG, "EXIF orientation for $filePath: $orientation")

// Calculate adjusted dimensions based on orientation
val (adjustedWidth, adjustedHeight) = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE -> {
// Swap width and height for 90° and 270° rotations
Log.d(TAG, "Swapping dimensions for orientation: $orientation")
Pair(pickerResult.height, pickerResult.width)
}
else -> {
// Keep original dimensions for 0°, 180°, and flips
Log.d(TAG, "Keeping original dimensions for orientation: $orientation")
Pair(pickerResult.width, pickerResult.height)
}
}

Log.d(TAG, "Adjusted dimensions: ${adjustedWidth}x${adjustedHeight} (orientation: $orientation)")

// Return result with adjusted dimensions and orientation angle
return pickerResult.copy(
width = adjustedWidth,
height = adjustedHeight,
orientation = exifOrientationToRotationDegrees(orientation)
)
} catch (e: IOException) {
Log.e(TAG, "IOException while adjusting orientation for $filePath", e)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OutOfMemoryError while processing image $filePath", e)
} catch (e: Exception) {
Log.e(TAG, "Unexpected error while adjusting orientation for $filePath", e)
}

// Fallback: try to read orientation info even if bitmap processing fails
return try {
val ei = ExifInterface(file.absolutePath)
val orientation = ei.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED
)
val rotation = exifOrientationToRotationDegrees(orientation)
Log.d(TAG, "Fallback: setting orientation to $rotation degrees (EXIF: $orientation) for $filePath")
pickerResult.copy(orientation = rotation)
} catch (e: Exception) {
Log.e(TAG, "Failed to read orientation in fallback for $filePath", e)
// Return original result with no rotation needed
pickerResult.copy(orientation = 0.0)
}
}

/**
* Maps EXIF orientation values to rotation degrees for image display.
* Returns the rotation angle needed to display the image correctly.
*/
private fun exifOrientationToRotationDegrees(exifOrientation: Int): Double {
return when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90.0
ExifInterface.ORIENTATION_ROTATE_180 -> 180.0
ExifInterface.ORIENTATION_ROTATE_270 -> -90.0
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> 0.0 // Flip, not rotate
ExifInterface.ORIENTATION_FLIP_VERTICAL -> 0.0 // Flip, not rotate
ExifInterface.ORIENTATION_TRANSPOSE -> 90.0 // Flip + rotate 90
ExifInterface.ORIENTATION_TRANSVERSE -> -90.0 // Flip + rotate -90
ExifInterface.ORIENTATION_NORMAL,
ExifInterface.ORIENTATION_UNDEFINED -> 0.0
else -> 0.0 // No rotation needed
}
}

override fun getAppContext(): Context {
return reactApplicationContext
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.margelo.nitro.multipleimagepicker

import androidx.exifinterface.media.ExifInterface
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class OrientationTest {

@Test
fun testOrientationValues() {
// Test all possible EXIF orientation values
val orientationTests = mapOf(
ExifInterface.ORIENTATION_NORMAL to "Normal",
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to "Flip Horizontal",
ExifInterface.ORIENTATION_ROTATE_180 to "Rotate 180°",
ExifInterface.ORIENTATION_FLIP_VERTICAL to "Flip Vertical",
ExifInterface.ORIENTATION_TRANSPOSE to "Transpose",
ExifInterface.ORIENTATION_ROTATE_90 to "Rotate 90° CW",
ExifInterface.ORIENTATION_TRANSVERSE to "Transverse",
ExifInterface.ORIENTATION_ROTATE_270 to "Rotate 270° CW",
ExifInterface.ORIENTATION_UNDEFINED to "Undefined"
)

orientationTests.forEach { (value, description) ->
assertTrue("$description orientation value should be valid", value >= 0)
assertTrue("$description orientation value should be within range", value <= 8)
}
}

@Test
fun testOrientationMapping() {
// Test that orientation values map correctly to transformations
val orientationMappings = mapOf(
ExifInterface.ORIENTATION_NORMAL to 0f,
ExifInterface.ORIENTATION_ROTATE_90 to 90f,
ExifInterface.ORIENTATION_ROTATE_180 to 180f,
ExifInterface.ORIENTATION_ROTATE_270 to 270f
)

orientationMappings.forEach { (orientation, expectedRotation) ->
val actualRotation = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
assertEquals("Orientation $orientation should map to $expectedRotation degrees", expectedRotation, actualRotation)
}
}

@Test
fun testPickerResultOrientationField() {
// Test that PickerResult can handle orientation values
val testResult = PickerResult(
localIdentifier = "test-id",
width = 1920.0,
height = 1080.0,
mime = "image/jpeg",
size = 1024000.0,
bucketId = null,
realPath = null,
parentFolderName = null,
creationDate = null,
crop = false,
orientation = ExifInterface.ORIENTATION_ROTATE_90.toDouble(),
path = "test/path",
type = ResultType.IMAGE,
duration = null,
thumbnail = null,
fileName = "test.jpg"
)

assertNotNull("PickerResult should have orientation field", testResult.orientation)
assertEquals("Orientation should be 90 degrees", ExifInterface.ORIENTATION_ROTATE_90.toDouble(), testResult.orientation!!)
}

@Test
fun testOrientationWithDifferentImageTypes() {
// Test orientation handling for different image types
val imageTypes = listOf(
"image/jpeg",
"image/png",
"image/webp",
"image/heic"
)

imageTypes.forEach { mimeType ->
val testResult = PickerResult(
localIdentifier = "test-id",
width = 1920.0,
height = 1080.0,
mime = mimeType,
size = 1024000.0,
bucketId = null,
realPath = null,
parentFolderName = null,
creationDate = null,
crop = false,
orientation = ExifInterface.ORIENTATION_ROTATE_90.toDouble(),
path = "test/path",
type = ResultType.IMAGE,
duration = null,
thumbnail = null,
fileName = "test.jpg"
)

assertEquals("$mimeType should preserve orientation", ExifInterface.ORIENTATION_ROTATE_90.toDouble(), testResult.orientation!!)
}
}

@Test
fun testOrientationForVideoFiles() {
// Test that video files don't have orientation applied
val videoResult = PickerResult(
localIdentifier = "video-test-id",
width = 1920.0,
height = 1080.0,
mime = "video/mp4",
size = 10240000.0,
bucketId = null,
realPath = null,
parentFolderName = null,
creationDate = null,
crop = false,
orientation = null, // Videos typically don't have orientation
path = "test/video/path",
type = ResultType.VIDEO,
duration = 120.0,
thumbnail = null,
fileName = "test.mp4"
)

assertEquals("Video should have VIDEO type", ResultType.VIDEO, videoResult.type)
// Note: Videos may or may not have orientation depending on implementation
}
}

Loading