How to unit test if an image was loaded using Coil + Compose - android

I'm loading an image using Coil for Compose like below.
#Composable
fun SvgImageSample() {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.decoderFactory(SvgDecoder.Factory())
.data("https://someserver.com/SVG_image.svg")
.size(Size.ORIGINAL)
.build()
)
Image(
painter = painter,
modifier = Modifier.size(100.dp).testTag("myImg"),
contentDescription = null
)
}
The image is loaded properly. Now, I would like to write a test to check if the image was loaded. Is there any assertion out-of-the-box for that?
Something like this:
class MyTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun checkIfTheImageLoads() {
composeTestRule.setContent {
MyAppThemeTheme {
SvgImageSample()
}
}
composeTestRule.onNodeWithTag("myImg")
.assertCoilImageIsLoaded() // <- this is what I want
}
}

I found what I was looking for... Please let me know if anyone has a better solution.
This is what I did:
Add this dependency in your build.gradle.
implementation "androidx.test.espresso.idling:idling-concurrent:3.5.0-alpha07"
This is necessary to use the IdlingThreadPoolExecutor class.
Declare the an IdlingThreadPool object like below:
object IdlingThreadPool: IdlingThreadPoolExecutor(
"coroutinesDispatchersThreadPool",
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors(),
0L,
TimeUnit.MILLISECONDS,
LinkedBlockingQueue(),
Executors.defaultThreadFactory()
)
I get this hint from this issue in the Coil github page.
Use the object declared above in the ImageRequest object.
#Composable
fun SvgImageSample() {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.dispatcher(IdlingThreadPool.asCoroutineDispatcher()) // << here
.decoderFactory(SvgDecoder.Factory())
.data("https://someserver.com/SVG_image.svg")
.size(Size.ORIGINAL)
.build()
)
Image(
painter = painter,
modifier = Modifier
.size(100.dp)
.semantics {
testTag = "myImg"
coilAsyncPainter = painter
},
contentDescription = null
)
}
Notice the IdlingThreadPool object was used in the dispatcher function. The other detail is coilAsyncPainter property which is receiving the painter object. It will be necessary during the test to check if the image was loaded.
Declare the coilAsyncPainter semantic property.
val CoilAsyncPainter = SemanticsPropertyKey<AsyncImagePainter>("CoilAsyncPainter")
var SemanticsPropertyReceiver.coilAsyncPainter by CoilAsyncPainter
This is what you need to do in the application code.
In the test code, declare a new SemanticNodeInteration.
fun SemanticsNodeInteraction.isAsyncPainterComplete(): SemanticsNodeInteraction {
assert(
SemanticsMatcher("Async Image is Success") { semanticsNode ->
val painter = semanticsNode.config.getOrElseNullable(CoilAsyncPainter) { null }
painter?.state is AsyncImagePainter.State.Success
}
)
return this;
}
So here, basically the painter object is obtained from the semantic property and then is checked if the current state is Success.
Finally, here it is the test.
class MyTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun async_image_was_displayed() {
composeTestRule.setContent {
MyAppThemeTheme {
SvgImageSample()
}
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("myImg")
.isAsyncPainterComplete()
}
}

Another way would be to implement an EventListener and check the right events are emitted. Will save you using testTags and semantic properties in the app code.
https://coil-kt.github.io/coil/api/coil-base/coil-base/coil/-event-listener/index.html
A quick hacky attempt, but you could wrap this in a Composable helper that does this for any block passed in.
#Test
fun imageLoader() {
var success = 0
var errors = 0
composeTestRule.setContent {
Coil.setImageLoader(
ImageLoader.Builder(context)
.eventListener(object : EventListener {
override fun onSuccess(
request: ImageRequest,
result: SuccessResult
) {
success++
}
override fun onError(
request: ImageRequest,
result: ErrorResult
) {
errors++
}
})
.build()
)
MyAppThemeTheme {
SvgImageSample()
}
}
Thread.sleep(500)
assertThat(errors).isEqualTo(0)
assertThat(success).isEqualTo(1)
}

First, I have to say that the approach suggested by #nglauber worked. However, I cringed at that level of complexity for a simple test, so I tried a straight forward test and that works as well and I will keep so.
First, I loaded the image simply with AsyncImage
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(template.previewUrl)
.crossfade(true)
.build(),
placeholder = painterResource(template.thumbNailResId),
contentDescription = stringResource(R.string.template_description),
contentScale = ContentScale.Fit,
)
Then in the test, I simply checked for the node with content description is displayed like so
#Test
fun intialImageDisplayedTest() {
val template = TemplateRepository.getTemplate()[0]
composeTestRule.setContent {
val selectedIndex = remember{ mutableStateOf(-1) }
TemplateItem(
selectedId = selectedIndex,
template = template,
onPreviewButtonClicked = {}
)
}
composeTestRule.onNodeWithTag("template_${template.templateId}").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(getImageDescriptionText()).assertIsDisplayed()
}
private fun getImageDescriptionText(): String {
return composeTestRule.activity.resources.getString(R.string.template_description)
}
Again keeping it simple. I also added a matcher with a test tag. No Idling resource needed.

Related

Can we or should use Preview compose function for main widget as well?

Like below are two functions
#Composable
private fun WaterCounter(modifier: Modifier = Modifier) {
val count = 0
Text(
text = "You've had $count glasses of water",
modifier = modifier.padding(all = 16.dp)
)
}
#Preview(showBackground = true)
#Composable
private fun PreviewWaterCounter() {
WaterCounter()
}
So, wouldn't it be better if we add #Preview annotation to the WaterCounter, which will save some lines of code and will work both as a preview and a widget?
For simple situations like your posted code, having a separate composable preview seems a bit too much, but consider this scenario with 2 composables with non-default parameters,
#Composable
fun PersonBiography(
details: Data,
otherParameters : Any?
) {
Box(
modifier = Modifier.background(Color.Red)
) {
Text(details.dataValue)
}
}
#Composable
fun AccountDetails(
details: Data
) {
Box(
modifier = Modifier.background(Color.Green)
) {
Text(details.dataValue)
}
}
both of them requires same data class , the first one has an additional parameter. If I have to preview them I have to break their signature, assigning default values to them just for the sake of the preview.
#Preview
#Composable
fun PersonBiography(
details: Data = Data(dataValue = ""),
otherParameters : Any? = null
) { … }
#Preview
#Composable
fun AccountDetails(
details: Data = Data(dataValue = "")
) { … }
A good workaround on this is having 2 separate preview composables and taking advantage of PreviewParameterProvider to have a re-usable utility that can provide instances of the parameters I needed.
class DetailsPreviewProvider : PreviewParameterProvider<Data> {
override val values = listOf(Data(dataValue = "Some Data")).asSequence()
}
#Preview
#Composable
fun PersonBiographyPreview(#PreviewParameter(DetailsPreviewProvider::class) details: Data) {
PersonBiography(
details = details,
// you may also consider creating a separate provider for this one if needed
null
)
}
#Preview
#Composable
fun AccountDetailsPreview(#PreviewParameter(DetailsPreviewProvider::class) details: Data) {
AccountDetails(details)
}
Or if PreviewParameterProvider is a bit too much, you can simply create a preview composable where you can create and supply the mock data.
#Preview
#Composable
fun AccountDetailsPreview() {
val data = Data("Some Account Information")
AccountDetails(data)
}
With any of these approaches, you don't need to break your actual composable's structure just to have a glimpse of what it would look like.

Crash when Kotlin Coroutine Not Complete in Android App

I am using Hilt dependency injection to retrieve data from a Firestore Database. I have a getResources coroutine which is called when by the init in my viewModel. In my view, I attempt to get the first x elements of the populated data. However, the app crashes with this error.
java.lang.IndexOutOfBoundsException: Empty list doesn't contain element at index 0.
at kotlin.collections.EmptyList.get(Collections.kt:36)
at kotlin.collections.EmptyList.get(Collections.kt:24)
I am guessing that the data load from Firestore has not completed, so when I try and get resources[i], it's empty and it crashes. This happens even when I do a null check. I cannot init{} without a coroutine and if I try and use a suspend function it doesn't like it either. How do I make the view wait until the viewmodel has the loaded data?
ViewModel.kt
#HiltViewModel
class ResourcesViewModel #Inject constructor(
private val repository: ResourcesRepository
) : ViewModel() {
val data: MutableState<DataOrException<List<Resource>, Exception>> = mutableStateOf(
DataOrException(
listOf(),
Exception("")
)
)
init {
getResources()
}
private fun getResources() {
viewModelScope.launch {
Log.d("getting resources", "currently getting resources")
data.value = repository.getResourcesFromFireStore()
Log.d("complete","complete")
}
}
}
ResourcesRepository.kt
#Singleton
class ResourcesRepository #Inject constructor(
private val db: FirebaseFirestore
) {
val resources = ArrayList<Resource>()
suspend fun getResourcesFromFireStore(): DataOrException<List<Resource>, Exception> {
val resourcesRef: CollectionReference = db.collection("updated-resources-new")
val dataOrException = DataOrException<List<Resource>, Exception>()
try {
dataOrException.data = resourcesRef.get().await().map { document ->
document.toObject(Resource::class.java)
}
Log.d(TAG, "${dataOrException.data}")
} catch (e: FirebaseFirestoreException) {
dataOrException.e = e
}
View.kt
#Composable
fun ResourcesScreenContent(viewModel: ResourcesViewModel) {
LazyColumn (
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp)
) {
val resources : List<Resource>? = viewModel.data.value.data
items(30) {
for (i in 0 ..30) {
ExpandableCard(resource = resources!![i])
Divider(thickness = 20.dp, color = Color.White)
}
}
}
}
How do I fix this?
you're doing one minor mistake, in forloop you are using 0...30 so it will run 30 times irrespective of resources list size. so in for loop you have to pass resources.count
#Composable
fun ResourcesScreenContent(viewModel: ResourcesViewModel) {
LazyColumn (
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp)
) {
val resources : List<Resource>? = viewModel.data.value.data
items(resources.count) { i ->
ExpandableCard(resource = resources!![i])
Divider(thickness = 20.dp, color = Color.White)
}
}
}
Hope it is clear my brother

Update LazyColumn after API response in Jetpack Compose

I am completely new to Jetpack Compose AND Kotlin, but not to Android development in Java. Wanting to make first contact with both technologies, I wanted to make a really simple app which populates a LazyColumn with images from Dog API.
All the Retrofit connection part works OK, as I've managed to populate one card with a random puppy, but when the time comes to populate the list, it's just impossible. This is what happens:
The interface is created and a white screen is shown.
The API is called.
Wait about 20 seconds (there's about 400 images!).
dogImages gets updated automatically.
The LazyColumn never gets recomposed again so the white screen stays like that.
Do you have any ideas? I can't find any tutorial on this matter, just vague explanations about state for scroll listening.
Here's my code:
class MainActivity : ComponentActivity() {
private val dogImages = mutableStateListOf<String>()
#ExperimentalCoilApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PuppyWallpapersTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
DogList(dogImages)
searchByName("poodle")
}
}
}
}
private fun getRetrofit():Retrofit {
return Retrofit.Builder()
.baseUrl("https://dog.ceo/api/breed/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private fun searchByName(query: String) {
CoroutineScope(Dispatchers.IO).launch {
val call = getRetrofit().create(APIService::class.java).getDogsByBreed("$query/images")
val puppies = call.body()
runOnUiThread {
if (call.isSuccessful) {
val images = puppies?.images ?: emptyList()
dogImages.clear()
dogImages.addAll(images)
}
}
}
}
#ExperimentalCoilApi
#Composable
fun DogList(dogs: SnapshotStateList<String>) {
LazyColumn() {
items(dogs) { dog ->
DogCard(dog)
}
}
}
#ExperimentalCoilApi
#Composable
fun DogCard(dog: String) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
elevation = 10.dp
) {
Image(
painter = rememberImagePainter(dog),
contentDescription = null
)
}
}
}
Thank you in advance! :)
Your view of the image cannot determine the aspect ratio before it loads, and it does not start loading because the calculated height is zero. See this reply for more information.
Also a couple of tips about your code.
Storing state inside MainActivity is bad practice, you can use view models. Inside a view model you can use viewModelScope, which will be bound to your screen: all tasks will be cancelled, and the object will be destroyed when the screen is closed.
You should not make state-modifying calls directly from the view constructor, as you do with searchByName. This code can be called many times during recomposition, so your call will be repetitive. You should do this with side effects. In this case you can use LaunchedEffect, but you can also do it in the init view model, because it will be created when your screen appears.
It's very convenient to pass Modifier as the last argument, in this case you don't need to add a comma at the end and you can easily add/remove modifiers.
You may have many composables, storing them all inside MainActivity is not very convenient. A good practice is to store them simply in a file, and separate them logically by files.
Your code can be updated to the following:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PuppyWallpapersTheme {
DogsListScreen()
}
}
}
}
#Composable
fun DogsListScreen(
// pass the view model in this form for convenient testing
viewModel: DogsModel = viewModel()
) {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
DogList(viewModel.dogImages)
}
}
#Composable
fun DogList(dogs: SnapshotStateList<String>) {
LazyColumn {
items(dogs) { dog ->
DogCard(dog)
}
}
}
#Composable
fun DogCard(dog: String) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
elevation = 10.dp
) {
Image(
painter = rememberImagePainter(
data = dog,
builder = {
// don't use it blindly, it can be tricky.
// check out https://stackoverflow.com/a/68908392/3585796
size(OriginalSize)
},
),
contentDescription = null,
)
}
}
class DogsModel : ViewModel() {
val dogImages = mutableStateListOf<String>()
init {
searchByName("poodle")
}
private fun getRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://dog.ceo/api/breed/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private fun searchByName(query: String) {
viewModelScope
.launch {
val call = getRetrofit()
.create(APIService::class.java)
.getDogsByBreed("$query/images")
val puppies = call.body()
if (call.isSuccessful) {
val images = puppies?.images ?: emptyList()
dogImages.clear()
dogImages.addAll(images)
}
}
}
}

Jetpack Compose does not update my list with RxAndroid

I'm trying to update a LazyColumn items using a subscriber to a RxAndroid Flowable. The state variable I'm using for the image list is called simply "list"
This is my LazyColumn code:
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
items(list) { image ->
Text(text = image.title ?: "Title")
}
}
If for example, I run this test coroutine, the list is updated and shows the correct amount of test images:
GlobalContext.run {
val testList = SnapshotStateList<Image>()
for (i in 1..100) {
testList.add(Image(i, null, null, null, null))
}
list = testList
}
But if I try the same method using my subscription to a Flowable, it updates the variable value but the recomposition is not triggered. This is my code:
val observer = remember {
disposable.add(
viewModel.imagesObservable().subscribe(
{ images ->
val snapList = SnapshotStateList<Image>()
images.forEach {
snapList.add(Image(it.id, it.albumId, it.title, it.url, it.thumbnailUrl))
}
list = snapList
},
{ Log.d("dasal", "Error: Can't load images") }
)
)
}
How do I handle a Flowable with a Composable?
Fixed it. I was using this declaration
var list = remember { mutableStateListOf<Image>() }
I changed it to this one instead
val list = remember { mutableStateOf(listOf<Image>()) }
Now I can use the list.value property to update/read the current value.

Jetpack compose update list element

I am currently trying to write an App for my thesis and currently, I am looking into different approaches. Since I really like Flutter and the Thesis requires me to use Java/Kotlin I would like to use Jetpack compose.
Currently, I am stuck trying to update ListElements.
I want to have a List that shows Experiments and their state/result. Once I hit the Button I want the experiments to run and after they are done update their state. Currently, the run Method does nothing besides setting the state to success.
The problem is I don't know how to trigger a recompose from the viewModel of the ExperimentRow once an experiment updates its state.
ExperimentsActivity:
class ExperimentsActivity : AppCompatActivity() {
val exViewModel by viewModels<ExperimentViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//For now this is just Dummy Data and will be replaced
exViewModel.experiments += listOf(
Experiment("Test1", exViewModel::experimentStateChanged),
Experiment("Strongbox", exViewModel::experimentStateChanged)
)
setContent {
TpmTheme {
// A surface container using the 'background' color from the theme
Surface {
ExperimentScreen(
exViewModel.experiments,
exViewModel::startTests
)
}
}
}
}
}
ExperimentViewModel:
class ExperimentViewModel : ViewModel() {
var experiments by mutableStateOf(listOf<Experiment>())
fun startTests() {
for (exp in experiments) {
exp.run()
}
}
fun experimentStateChanged(experiment: Experiment) {
Log.i("ViewModel", "Changed expState of ${experiment.name} to ${experiment.state}")
// HOW DO I TRIGGER A RECOMPOSE OF THE EXPERIMENTROW FOR THE experiment????
//experiments = experiments.toMutableList().also { it.plus(experiment) }
Log.i("Vi", "Size of Expirments: ${experiments.size}")
}
}
ExperimentScreen:
#Composable
fun ExperimentScreen(
experiments: List<Experiment>,
onStartExperiments: () -> Unit
) {
Column {
LazyColumnFor(
items = experiments,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(top = 8.dp),
) { ep ->
ExperimentRow(
experiment = ep,
modifier = Modifier.fillParentMaxWidth(),
)
}
Button(
onClick = { onStartExperiments() },
modifier = Modifier.padding(16.dp).fillMaxWidth(),
) {
Text("Run Tests")
}
}
}
#Composable
fun ExperimentRow(experiment: Experiment, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(experiment.name)
Icon(
asset = experiment.state.vAsset,
)
}
Experiment:
class Experiment(val name: String, val onStateChanged: (Experiment) -> Unit) {
var state: ExperimentState = ExperimentState.DEFAULT
set(value) {
field = value
onStateChanged(this)
}
fun run() {
state = ExperimentState.SUCCESS;
}
}
enum class ExperimentState(val vAsset: VectorAsset) {
DEFAULT(Icons.Default.Info),
RUNNING(Icons.Default.Refresh),
SUCCESS(Icons.Default.Done),
FAILED(Icons.Default.Warning),
}
There's a few ways to address this but key thing is that you need to add a copy of element (with state changed) to experiments to trigger the recomposition.
One possible example would be
data class Experiment(val name: String, val state: ExperimentState, val onStateChanged: (Experiment) -> Unit) {
fun run() {
onStateChanged(this.copy(state = ExperimentState.SUCCESS))
}
}
and then
fun experimentStateChanged(experiment: Experiment) {
val index = experiments.toMutableList().indexOfFirst { it.name == experiment.name }
experiments = experiments.toMutableList().also {
it[index] = experiment
}
}
though I suspect there's probably cleaner way of doing this.

Categories

Resources