Search code examples
androidkotlinviewmodel

How can I check to see if JSON data is null without an infinite loop?


I have a viewmodel and data classes that fetch the NASA api for photos of Mars. The user should be displayed images from a random date queried. I always need an image url (imgSrc in Photo class) returned. If no url (imgSrc) is found, refresh data until one is found and display it. This logic would need to return an imgSrc following launch of the application as well as after swiperefreshlayout if the user chooses to swipe to refresh. I have been stuck on this for a week with no resolve. What is the best way to handle this? Even if I have to refactor my code I would like to be pointed in the right direction.

Here is the actual project on github.

JSON that I want to fetch

JSON returning no imgSrc

viewmodel

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.dev20.themarsroll.models.MarsPhotos
import com.dev20.themarsroll.models.Photo
import com.dev20.themarsroll.repository.MarsPhotoRepository
import com.dev20.themarsroll.util.Resource
import kotlinx.coroutines.launch
import retrofit2.Response

class MarsPhotoViewModel(

    private val marsPhotoRepository: MarsPhotoRepository
    ): ViewModel() {

    val marsPhotos: MutableLiveData<Resource<MarsPhotos>> = MutableLiveData()

    init {
        getRandomPhotos()
    }

     fun getCuriosityPhotos(solQuery: Int, roverQuery: Int, camera: String) = viewModelScope.launch {
        marsPhotos.postValue(Resource.Loading())
        val response = marsPhotoRepository.getCuriosityPhotos(solQuery, roverQuery, camera)
        marsPhotos.postValue(handlePhotosResponse(response))
    }

    private fun handlePhotosResponse(response: Response<MarsPhotos> ) : Resource<MarsPhotos> {
        if(response.isSuccessful) {
                response.body()?.let { resultResponse ->
                    return Resource.Success(resultResponse)
            }
        }
        return Resource.Error(response.message())
    }

    fun getRandomPhotos() {
        getCuriosityPhotos((1..2878).random(), 5, "NAVCAM")
    }

    fun savePhoto(photo: Photo) = viewModelScope.launch {
        marsPhotoRepository.upsert(photo)
    }

    fun getSavedPhotos() = marsPhotoRepository.getSavedPhotos()

    fun deletePhoto(photo: Photo) = viewModelScope.launch {
        marsPhotoRepository.deletePhoto(photo)
    }
}

CuriosityFragment


import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.dev20.themarsroll.R
import com.dev20.themarsroll.adapters.MarsPhotoAdapter
import com.dev20.themarsroll.util.Resource
import com.dev20.ui.MarsActivity
import com.dev20.ui.MarsPhotoViewModel
import kotlinx.android.synthetic.main.fragment_curiosity.*


class CuriosityFragment : Fragment(R.layout.fragment_curiosity) {
    lateinit var viewModel: MarsPhotoViewModel
    lateinit var marsPhotoAdapter: MarsPhotoAdapter
    
    val TAG = "CuriosityFragment"

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel = (activity as MarsActivity).viewModel
        setupRecyclerView()

        swipeLayout.setOnRefreshListener {
            viewModel.getRandomPhotos()
            swipeLayout.isRefreshing = false
        }

        marsPhotoAdapter.setOnItemClickListener {
            val bundle = Bundle().apply {
                putSerializable("photo", it)
            }
            findNavController().navigate(
                R.id.action_curiosityFragment_to_cameraFragment,
                bundle
            )
        }

        viewModel.marsPhotos.observe(viewLifecycleOwner, { response ->
            when(response) {
                is Resource.Success -> {
                    hideProgressBar()
                    response.data?.let { curiosityResponse ->
                    marsPhotoAdapter.differ.submitList(curiosityResponse.photos)
                    }
                }
                is Resource.Error -> {
                    hideProgressBar()
                    response.message?.let { message ->
                        Log.e(TAG, "An Error occurred: $message")
                    }
                }
                is Resource.Loading -> {
                    showProgressBar()
                }
            }
        })
    }

    private fun hideProgressBar() {
        curiosityPaginationProgressBar.visibility = View.INVISIBLE
    }

    private fun showProgressBar() {
        curiosityPaginationProgressBar.visibility = View.VISIBLE
    }

    private fun setupRecyclerView() {
        marsPhotoAdapter = MarsPhotoAdapter()
        rvCuriosityPhotos.apply {
        adapter = marsPhotoAdapter
            layoutManager = LinearLayoutManager(activity)
        }
    }
}

MarsPhoto data class

data class MarsPhotos(
    val photos: MutableList<Photo>,
    val camera: MutableList<Camera>
)

Photo data class


import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.google.gson.annotations.SerializedName
import java.io.Serializable

@Entity(
    tableName = "photos"
)

@TypeConverters
data class Photo(
    @PrimaryKey(autoGenerate = true)
    var id: Int? = null,
    @SerializedName("earth_date")
    val earthDate: String,
    @SerializedName("img_src")
    val imgSrc: String,
    val sol: Int,
    @SerializedName("rover_id")
    val rover: Int,
) : Serializable

Solution

  • There are many potential solutions here that I can think of. However, given the app needs to have predictable and reasonable user experience, herein I'm scoping out the issues first.

    • Since a random resource is being requested each time, there's always a chance of it being null. Hence, multiple round-trips cannot be done away with (but can be reduced).
    • Multiple HTTP round-trips, that too with the unpredictability of returning null several times, can be really frustrating user experience.

    Below are the potential ways (in increasing order of complexity) this can be dealt with.

    1. The simplest solution is to implement logic on the repository level, wherein the function getCuriosityPhotos is responsible to request the api resource indefinitely till it responds with a not null data. This will solve the core issue that the user will eventually be shown something (but it might take a hell lot of time).

    (PS- You'll also need to delegate the random number generation as a potential service available to the repository.)

    1. To reduce the number of requests and hence wait-time for the user, you can save to the in-app database, the request params as well as the response. Thus, your database can act as a single source of truth. Hence, before making a request, you can query the database to check if the app had previously requested the same params earlier. If it did not, dispatch the request else if it did, then there's no need to request again and you can use the previous result. If it was null, regenerate another random number and try again. If it was not null, serve the data from the database. (This is a good enough solution and as more and more requests & responses are saved, user wait time would continually reduce)

    (Note: In case the endpoints do not respond with static data and the data keeps changing, prefer using an in-memory database than a persistent database such as SQLite)

    1. The app can run a background service that continually (by iterating over all possible combinations of the request params) requests and saves the data into the database. When the user requests random data, the app should display a random set of data from within the database. If the database is empty/does not meet a threshold of having at least n number of rows in the database, the app can perhaps show an initialization setup UI.

    Pro-tip: Ideally (and in case you are building a product/service), mobile apps are meant to be very very predictable and have to be mindful of a user's time. Hence, the very task of requesting data from such resources should be a task of a backend server and database which operate some sort of service to fetch and store data and in-turn the app would request this server to fetch data amongst this subset which does not have any null values.

    I've answered this question from a perspective of solving the problem with varying granularity. In case you need help/advice on the technical implementation part, let me know, I'll be happy to help!