Skip to content

Commit c04e0bd

Browse files
Add UriImage component for displaying images from URIs (#3563)
- Implement `UriImage` using Coil's `AsyncImage` with support for placeholders, error states, and crossfade transitions. - Optimize image loading by calculating target dimensions based on available constraints or display metrics, capped at 2048px to prevent memory issues. - Add `UriImageTest` to verify image loading behavior, including null URI handling, dimension scaling, and constraint coercion.
1 parent fadfe47 commit c04e0bd

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

  • app/src
    • main/java/org/groundplatform/android/ui/datacollection/components
    • test/java/org/groundplatform/android/ui/datacollection/components
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.groundplatform.android.ui.datacollection.components
17+
18+
import android.net.Uri
19+
import androidx.annotation.VisibleForTesting
20+
import androidx.compose.foundation.layout.BoxWithConstraints
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.layout.ContentScale
24+
import androidx.compose.ui.platform.LocalContext
25+
import androidx.compose.ui.platform.LocalResources
26+
import androidx.compose.ui.res.stringResource
27+
import androidx.compose.ui.tooling.preview.Preview
28+
import androidx.core.net.toUri
29+
import coil.compose.AsyncImage
30+
import coil.request.ImageRequest
31+
import coil.size.Scale
32+
import org.groundplatform.android.R
33+
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
34+
import org.groundplatform.android.ui.theme.AppTheme
35+
36+
@VisibleForTesting const val MAX_IMAGE_SIZE = 2048
37+
38+
@Composable
39+
fun UriImage(uri: Uri?, modifier: Modifier = Modifier) {
40+
val context = LocalContext.current
41+
val displayMetrics = LocalResources.current.displayMetrics
42+
43+
// Determine target dimensions to avoid loading very large images into memory.
44+
// - Prefer the view’s measured size if available, else fall back to half the screen size.
45+
// - Clamp to a maximum of 2048px to keep decoding efficient and prevent OOM on huge images.
46+
BoxWithConstraints(modifier = modifier) {
47+
val measureW =
48+
if (constraints.hasBoundedWidth) constraints.maxWidth else displayMetrics.widthPixels / 2
49+
val measureH =
50+
if (constraints.hasBoundedHeight) constraints.maxHeight else displayMetrics.heightPixels / 2
51+
52+
val targetW = measureW.coerceAtMost(MAX_IMAGE_SIZE)
53+
val targetH = measureH.coerceAtMost(MAX_IMAGE_SIZE)
54+
55+
AsyncImage(
56+
model =
57+
ImageRequest.Builder(context)
58+
.data(uri)
59+
.size(width = targetW, height = targetH)
60+
.scale(Scale.FIT)
61+
.placeholder(R.drawable.ic_photo_grey_600_24dp)
62+
.error(R.drawable.outline_error_outline_24)
63+
.crossfade(true)
64+
.build(),
65+
contentDescription = stringResource(id = R.string.photo_preview),
66+
contentScale = ContentScale.Fit,
67+
)
68+
}
69+
}
70+
71+
@Preview(showBackground = true)
72+
@Composable
73+
@ExcludeFromJacocoGeneratedReport
74+
private fun UriImagePreview() {
75+
AppTheme { UriImage(uri = "content://media/external/images/media/1".toUri()) }
76+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.groundplatform.android.ui.datacollection.components
17+
18+
import android.content.Context
19+
import android.graphics.Bitmap
20+
import android.graphics.drawable.BitmapDrawable
21+
import android.net.Uri
22+
import androidx.compose.foundation.layout.requiredSize
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.layout.Layout
25+
import androidx.compose.ui.unit.Constraints
26+
import androidx.compose.ui.unit.dp
27+
import androidx.test.core.app.ApplicationProvider
28+
import coil.Coil
29+
import coil.ImageLoader
30+
import coil.decode.DataSource
31+
import coil.request.ImageRequest
32+
import coil.request.SuccessResult
33+
import coil.size.Dimension.Pixels
34+
import coil.size.Scale
35+
import com.google.common.truth.Truth.assertThat
36+
import dagger.hilt.android.testing.HiltAndroidTest
37+
import kotlinx.coroutines.ExperimentalCoroutinesApi
38+
import org.groundplatform.android.BaseHiltTest
39+
import org.junit.After
40+
import org.junit.Before
41+
import org.junit.Test
42+
import org.junit.runner.RunWith
43+
import org.robolectric.RobolectricTestRunner
44+
import org.robolectric.annotation.Config
45+
46+
@HiltAndroidTest
47+
@RunWith(RobolectricTestRunner::class)
48+
@OptIn(ExperimentalCoroutinesApi::class)
49+
@Config(qualifiers = "w480dp-h1080dp-mdpi")
50+
class UriImageTest : BaseHiltTest() {
51+
52+
private lateinit var context: Context
53+
private val capturedRequests = mutableListOf<ImageRequest>()
54+
55+
@Before
56+
override fun setUp() {
57+
super.setUp()
58+
59+
context = ApplicationProvider.getApplicationContext()
60+
capturedRequests.clear()
61+
62+
setupImageLoader()
63+
}
64+
65+
@After
66+
fun tearDown() {
67+
// Reset ImageLoader to avoid leaking state between tests
68+
Coil.setImageLoader(ImageLoader(context))
69+
}
70+
71+
@Test
72+
fun uriImage_nullUri_doesNotLoadImage() = runWithTestDispatcher {
73+
composeTestRule.setContent { UriImage(uri = null) }
74+
75+
assertThat(capturedRequests).isEmpty()
76+
}
77+
78+
@Test
79+
fun uriImage_loadsImageWithCorrectSpecs() = runWithTestDispatcher {
80+
composeTestRule.setContent {
81+
UriImage(uri = URI, modifier = Modifier.requiredSize(100.dp, 100.dp))
82+
}
83+
84+
composeTestRule.waitForIdle()
85+
86+
verifyImageDimensions(100, 100)
87+
}
88+
89+
@Test
90+
fun uriImage_largeBoundedSize_isCoercedToMaxImageSize() = runWithTestDispatcher {
91+
composeTestRule.setContent {
92+
UriImage(uri = URI, modifier = Modifier.requiredSize(3000.dp, 3000.dp))
93+
}
94+
composeTestRule.waitForIdle()
95+
96+
verifyImageDimensions(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
97+
}
98+
99+
@Test
100+
fun uriImage_unboundedSize_usesHalvedDisplayMetrics() = runWithTestDispatcher {
101+
composeTestRule.setContent {
102+
Layout(content = { UriImage(uri = URI) }) { measurables, _ ->
103+
val constraints = Constraints()
104+
val placeable = measurables[0].measure(constraints)
105+
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
106+
}
107+
}
108+
composeTestRule.waitForIdle()
109+
110+
verifyImageDimensions(240, 540)
111+
}
112+
113+
private fun setupImageLoader() {
114+
val imageLoader =
115+
ImageLoader.Builder(context)
116+
.components {
117+
add { chain ->
118+
capturedRequests.add(chain.request)
119+
SuccessResult(
120+
drawable =
121+
BitmapDrawable(
122+
context.resources,
123+
Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888),
124+
),
125+
request = chain.request,
126+
dataSource = DataSource.MEMORY_CACHE,
127+
)
128+
}
129+
}
130+
.build()
131+
132+
Coil.setImageLoader(imageLoader)
133+
}
134+
135+
private suspend fun verifyImageDimensions(width: Int, height: Int) {
136+
assertThat(capturedRequests).hasSize(1)
137+
138+
val request = capturedRequests.first()
139+
assertThat(request.data).isEqualTo(URI)
140+
assertThat(request.scale).isEqualTo(Scale.FIT)
141+
assertThat(request.sizeResolver).isNotNull()
142+
143+
// Check that transitionFactory is set (implies crossfade was called)
144+
assertThat(request.transitionFactory).isNotNull()
145+
146+
val size = request.sizeResolver.size()
147+
assertThat((size.width as Pixels).px).isEqualTo(width)
148+
assertThat((size.height as Pixels).px).isEqualTo(height)
149+
}
150+
151+
companion object {
152+
private val URI = Uri.parse("content://test/image.jpg")
153+
}
154+
}

0 commit comments

Comments
 (0)