Skip to content
Merged
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ androidxComposeBom = "2025.08.01"
androidxComposeConstraintLayout = "1.1.0"
androidxCoreSplashScreen = "1.2.0"
androidxCoreTest = "2.2.0"
androidxExifInterface = "1.3.7"
androidxFragment = "1.8.5"
androidxKtx = "1.15.0"
androidxLifecycle = "2.8.7"
Expand Down Expand Up @@ -114,6 +115,7 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxKtx"}
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxCoreSplashScreen"}
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidxCoreTest"}
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidxExifInterface"}
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidxFragment"}
androidx-fragment-testing = { module = "androidx.fragment:fragment-testing", version.ref = "androidxFragment"}
androidx-legacy-support = { module = "androidx.legacy:legacy-support-v4", version.ref = "androidLegacySupport"}
Expand Down
1 change: 1 addition & 0 deletions stream-chat-android-ui-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ dependencies {
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.startup.runtime)
implementation(libs.androidx.constraintlayout)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.exifinterface.media.ExifInterface
import io.getstream.chat.android.client.extensions.EXTRA_DURATION
import io.getstream.chat.android.client.utils.attachment.isImage
import io.getstream.chat.android.client.utils.attachment.isVideo
Expand Down Expand Up @@ -171,7 +172,7 @@ public class AttachmentStorageHelper(
BitmapFactory.decodeFile(file.absolutePath, options)
val w = options.outWidth.takeIf { it > 0 }
val h = options.outHeight.takeIf { it > 0 }
w to h
if (w != null && h != null && hasSwappedExifDimensions(file)) h to w else w to h
}

attachment.isVideo() -> {
Expand All @@ -195,6 +196,18 @@ public class AttachmentStorageHelper(
else -> null to null
}

private fun hasSwappedExifDimensions(file: File): Boolean = try {
val orientation = ExifInterface(file.absolutePath)
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
orientation == ExifInterface.ORIENTATION_ROTATE_270 ||
orientation == ExifInterface.ORIENTATION_TRANSPOSE ||
orientation == ExifInterface.ORIENTATION_TRANSVERSE
} catch (_: Exception) {
false
}

public companion object {
/**
* Key in [Attachment.extraData] holding the original content URI string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
*
* 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 io.getstream.chat.android.ui.common.helper.internal

import android.content.Context
import androidx.exifinterface.media.ExifInterface
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.util.Base64

@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [Config.NEWEST_SDK])
internal class AttachmentStorageHelperExifTest(private val case: Case) {

@get:Rule
val tempFolder = TemporaryFolder()

private val context: Context = mock()
private val storageHelper: StorageHelper = mock()
private val attachmentFilter: AttachmentFilter = mock {
on { filterAttachments(any()) } doAnswer { it.getArgument(0) }
}
private val sut = AttachmentStorageHelper(context, storageHelper, attachmentFilter)

@Test
fun `resolveAttachmentFiles applies EXIF orientation to the resolved image dimensions`() = runTest {
val file = createJpeg(case.orientation)
whenever(storageHelper.getCachedFileFromUri(any(), any())) doReturn file
val attachment = Attachment(
type = "image",
mimeType = "image/jpeg",
name = "photo.jpg",
extraData = mapOf(EXTRA_SOURCE_URI to "content://media/external/images/1"),
)

val resolved = sut.resolveAttachmentFiles(listOf(attachment)).single()

assertEquals(case.expectedWidth, resolved.originalWidth)
assertEquals(case.expectedHeight, resolved.originalHeight)
}

private fun createJpeg(orientation: Int): File {
val file = tempFolder.newFile("image.jpg")
file.writeBytes(Base64.getDecoder().decode(LANDSCAPE_JPEG_BASE64))
ExifInterface(file.absolutePath).apply {
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
saveAttributes()
}
return file
}

internal data class Case(
val description: String,
val orientation: Int,
val expectedWidth: Int,
val expectedHeight: Int,
) {
override fun toString(): String = description
}

internal companion object {
private const val RAW_WIDTH = 80
private const val RAW_HEIGHT = 40

@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun cases(): List<Case> = listOf(
// Orientations that preserve the aspect ratio -> dimensions kept as-is.
Case("normal keeps dimensions", ExifInterface.ORIENTATION_NORMAL, RAW_WIDTH, RAW_HEIGHT),
Case("flip horizontal keeps dimensions", ExifInterface.ORIENTATION_FLIP_HORIZONTAL, RAW_WIDTH, RAW_HEIGHT),
Case("rotate 180 keeps dimensions", ExifInterface.ORIENTATION_ROTATE_180, RAW_WIDTH, RAW_HEIGHT),
// Orientations that swap the aspect ratio -> dimensions swapped.
Case("rotate 90 swaps dimensions", ExifInterface.ORIENTATION_ROTATE_90, RAW_HEIGHT, RAW_WIDTH),
Case("rotate 270 swaps dimensions", ExifInterface.ORIENTATION_ROTATE_270, RAW_HEIGHT, RAW_WIDTH),
Case("transpose swaps dimensions", ExifInterface.ORIENTATION_TRANSPOSE, RAW_HEIGHT, RAW_WIDTH),
Case("transverse swaps dimensions", ExifInterface.ORIENTATION_TRANSVERSE, RAW_HEIGHT, RAW_WIDTH),
)

private const val LANDSCAPE_JPEG_BASE64 =
"/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a" +
"HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy" +
"MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoAFADASIA" +
"AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA" +
"AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3" +
"ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm" +
"p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA" +
"AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx" +
"BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK" +
"U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3" +
"uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDhqKKK" +
"k+hCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoooo" +
"A//2Q=="
}
}
Loading