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 @@ -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"
Expand Down Expand Up @@ -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"}
Expand Down
1 change: 1 addition & 0 deletions stream-chat-android-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int?, Int?> = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,15 @@ public class StorageHelperWrapper(
return@mapNotNull null
}

Attachment(
val attachment = Attachment(
upload = fileFromUri,
type = it.type,
name = it.title ?: fileFromUri?.name ?: "",
fileSize = it.size.toInt(),
mimeType = it.mimeType,
extraData = it.extraData,
)
LocalAttachmentDimensionsResolver.resolveDimensions(attachment, fileFromUri)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<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=="
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading