diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4646e12ede3..b79d081762c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ androidxComposeConstraintLayout = "1.1.0" androidxComposeMaterial3 = "1.3.1" androidxComposeMaterial3Adaptive = "1.0.0" androidxCoreTest = "2.2.0" +androidxExifInterface = "1.3.7" androidxFragment = "1.8.5" androidxKtx = "1.15.0" androidxLifecycle = "2.8.7" @@ -114,6 +115,7 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", versi androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout"} androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxKtx"} 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"} diff --git a/stream-chat-android-compose/build.gradle.kts b/stream-chat-android-compose/build.gradle.kts index 7d306ff302b..45baf3beed7 100644 --- a/stream-chat-android-compose/build.gradle.kts +++ b/stream-chat-android-compose/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { implementation(project(":stream-chat-android-ui-utils")) implementation(libs.androidx.appcompat) + implementation(libs.androidx.exifinterface) implementation(libs.stream.log) // Compose diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolver.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolver.kt new file mode 100644 index 00000000000..50ab88f12ec --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolver.kt @@ -0,0 +1,99 @@ +/* + * 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.compose.ui.util + +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import androidx.annotation.WorkerThread +import androidx.exifinterface.media.ExifInterface +import io.getstream.chat.android.client.utils.attachment.isImage +import io.getstream.chat.android.client.utils.attachment.isVideo +import io.getstream.chat.android.models.Attachment +import java.io.File + +/** + * Resolves the original dimensions of locally picked image and video attachments by decoding the + * cached file, so that [Attachment.originalWidth] and [Attachment.originalHeight] are populated + * before upload. + * + * This is the only place that ever sees the file bytes for attachments uploaded through a custom + * CDN, so it is the only place dimensions can be backfilled client-side. + * + * IMPORTANT: decoding reads the file from disk and must be called off the main thread. + */ +internal object LocalAttachmentDimensionsResolver { + + /** + * Returns [attachment] with [Attachment.originalWidth]/[Attachment.originalHeight] decoded from + * [file]. Unchanged if dimensions are already set, [file] is `null`, or decoding fails. + */ + @WorkerThread + fun resolveDimensions(attachment: Attachment, file: File?): Attachment { + if (file == null) return attachment + if (attachment.originalWidth != null || attachment.originalHeight != null) return attachment + + val (width, height) = resolveLocalDimensions(file, attachment) + return if (width != null || height != null) { + attachment.copy(originalWidth = width, originalHeight = height) + } else { + attachment + } + } + + @Suppress("MagicNumber") + private fun resolveLocalDimensions(file: File, attachment: Attachment): Pair = when { + attachment.isImage() -> { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, options) + val w = options.outWidth.takeIf { it > 0 } + val h = options.outHeight.takeIf { it > 0 } + if (w != null && h != null && hasSwappedExifDimensions(file)) h to w else w to h + } + + attachment.isVideo() -> { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(file.absolutePath) + val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull()?.takeIf { it > 0 } + val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull()?.takeIf { it > 0 } + val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + if (rotation == 90 || rotation == 270) h to w else w to h + } catch (_: Exception) { + null to null + } finally { + retriever.release() + } + } + + 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 + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt index a6c47a475d5..68a57eb68e1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt @@ -89,7 +89,7 @@ public class StorageHelperWrapper( return@mapNotNull null } - Attachment( + val attachment = Attachment( upload = fileFromUri, type = it.type, name = it.title ?: fileFromUri?.name ?: "", @@ -97,6 +97,7 @@ public class StorageHelperWrapper( mimeType = it.mimeType, extraData = it.extraData, ) + LocalAttachmentDimensionsResolver.resolveDimensions(attachment, fileFromUri) } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolverExifTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolverExifTest.kt new file mode 100644 index 00000000000..05d9c83b490 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolverExifTest.kt @@ -0,0 +1,105 @@ +/* + * 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.compose.ui.util + +import androidx.exifinterface.media.ExifInterface +import io.getstream.chat.android.models.Attachment +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.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 LocalAttachmentDimensionsResolverExifTest(private val case: Case) { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `resolveDimensions applies EXIF orientation to the resolved image dimensions`() { + val file = createJpeg(case.orientation) + val attachment = Attachment( + type = "image", + mimeType = "image/jpeg", + name = "photo.jpg", + ) + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file) + + 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 = 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==" + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolverTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolverTest.kt new file mode 100644 index 00000000000..c7aa9d421d8 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/LocalAttachmentDimensionsResolverTest.kt @@ -0,0 +1,139 @@ +/* + * 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.compose.ui.util + +import android.media.MediaMetadataRetriever +import io.getstream.chat.android.models.Attachment +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowMediaMetadataRetriever +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Config.NEWEST_SDK]) +internal class LocalAttachmentDimensionsResolverTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @After + fun tearDown() { + ShadowMediaMetadataRetriever.reset() + } + + @Test + fun `resolveDimensions keeps video dimensions when rotation does not swap them`() { + val file = videoFile(rotation = 180) + val attachment = videoAttachment() + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file) + + assertEquals(RAW_WIDTH, resolved.originalWidth) + assertEquals(RAW_HEIGHT, resolved.originalHeight) + } + + @Test + fun `resolveDimensions swaps video dimensions when rotation is 90`() { + val file = videoFile(rotation = 90) + val attachment = videoAttachment() + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file) + + assertEquals(RAW_HEIGHT, resolved.originalWidth) + assertEquals(RAW_WIDTH, resolved.originalHeight) + } + + @Test + fun `resolveDimensions swaps video dimensions when rotation is 270`() { + val file = videoFile(rotation = 270) + val attachment = videoAttachment() + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file) + + assertEquals(RAW_HEIGHT, resolved.originalWidth) + assertEquals(RAW_WIDTH, resolved.originalHeight) + } + + @Test + fun `resolveDimensions returns attachment unchanged when file is null`() { + val attachment = imageAttachment() + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file = null) + + assertSame(attachment, resolved) + } + + @Test + fun `resolveDimensions does not overwrite already populated dimensions`() { + val file = videoFile(rotation = 90) + val attachment = videoAttachment().copy(originalWidth = 123, originalHeight = 456) + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file) + + assertEquals(123, resolved.originalWidth) + assertEquals(456, resolved.originalHeight) + } + + @Test + fun `resolveDimensions returns attachment unchanged for non-media attachments`() { + val file = tempFolder.newFile("document.pdf") + val attachment = Attachment(type = "file", mimeType = "application/pdf", name = "document.pdf") + + val resolved = LocalAttachmentDimensionsResolver.resolveDimensions(attachment, file) + + assertNull(resolved.originalWidth) + assertNull(resolved.originalHeight) + } + + private fun videoFile(rotation: Int): File { + val file = tempFolder.newFile("video.mp4") + val path = file.absolutePath + ShadowMediaMetadataRetriever.addMetadata( + path, + MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH, + RAW_WIDTH.toString(), + ) + ShadowMediaMetadataRetriever.addMetadata( + path, + MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, + RAW_HEIGHT.toString(), + ) + ShadowMediaMetadataRetriever.addMetadata( + path, + MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, + rotation.toString(), + ) + return file + } + + private fun videoAttachment() = Attachment(type = "video", mimeType = "video/mp4", name = "video.mp4") + + private fun imageAttachment() = Attachment(type = "image", mimeType = "image/jpeg", name = "photo.jpg") + + private companion object { + private const val RAW_WIDTH = 80 + private const val RAW_HEIGHT = 40 + } +}