Search code examples
androidandroid-architecture-componentsandroid-livedata

Single event live data emits null values multiple times will giving the correct result


I have simple login in my app which validates username and password in server side and base on that show a toast message.

LoginFragment

private fun onClickLogin(view: View) {

        view.loginButton.setOnClickListener {

            val emailAddress = view.emailTextInputEditText.text.toString()
            val password = view.passwordTextInputEditText.text.toString()

            viewmodel.generalLogin(emailAddress, password).observe(viewLifecycleOwner, Observer {

                if(it != null){

                    if (it.status) {
                        Toast.makeText(
                            context,
                            "Hi, " + it.data?.displayName,
                            Toast.LENGTH_SHORT
                        ).show()

                        val sharedPref = PreferenceManager
                            .getDefaultSharedPreferences(context)
                        val editor = sharedPref.edit()

                        editor.putString(getString(R.string.user_id), it.data?.email).apply()
                        editor.putString(getString(R.string.user_name), it.data?.email).apply()

                        activity?.finish()

                    } else {

                        Toast.makeText(
                            context,
                            "Error, " + it.message,
                            Toast.LENGTH_SHORT
                        ).show()
                    }
                }

            })

        }

    }

LoginViewModel

fun generalLogin(email: String, password: String): LiveData<Resource<UserSession>> {

        val encryptedPassword = MCryptHelper.bytesToHex(MCryptHelper().encrypt(password))

        return Transformations.switchMap(loginRepository.generalLogin(email, encryptedPassword)) {
            it.getContentIfNotHandled().let{ resource ->
                val userSessionLiveData = MutableLiveData<Resource<UserSession>>()
                userSessionLiveData.value = resource
                return@switchMap userSessionLiveData
            }

        }
    }

LoginRepository

fun generalLogin(email: String, encryptedPassword: String):MutableLiveData<SingleLiveEvent<Resource<UserSession>>>{

        val login = Global.network.login(email, encryptedPassword)

        login.enqueue(object : Callback<LoginResponse> {

            override fun onResponse(call: Call<LoginResponse>, response: Response<LoginResponse>) {

                if(response.body()?.status == 1){
                    val resource = Resource<UserSession>(true,"Success")
                    response.body().let {
                        if(it?.session != null){
                            resource.data = UserSession(it.session.userId!!,it.session.fullName!!)
                        }
                    }

                    loginMutableData.value = SingleLiveEvent(resource)

                }else{

                    val resource = Resource<UserSession>(false,response.body()?.msg ?: "Login failed. Try again")
                    loginMutableData.value  = SingleLiveEvent(resource)
                }
            }

            override fun onFailure(call: Call<LoginResponse>, t: Throwable) {
                loginMutableData.value = SingleLiveEvent(Resource(false, t.localizedMessage))
            }

        })

        return loginMutableData

    }

SingleLiveEvent

class SingleLiveEvent<out T>(private val content: T){

    var hasBeenHandled = false
        private set // Allow external read but not write

    /**
     * Returns the content and prevents its use again.
     */
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    /**
     * Returns the content, even if it's already been handled.
     */
    fun peekContent(): T = content
}

This example works fine for the user. but what I notice is from LoginViewModel generalLogin function, it emits null values to the fragment multiple times while giving the write value among them. This app only works without crash because I have handle null check on LoginFragment. It seems to increase the number of null emits when you try more and more with incorrect login credentials.

Is there a better approach to solve this problem? It would be nice if there's a way to handle this in a way if result is null from getContentIfNotHandled() not to emit any thing at all so that nothing to observe in Fragment.

Give your suggestions. Thanks.


Solution

  • Each layer has some mistakes with varying importance. Let's go over them one by one.


    [1] Fragment attaching observer inside of a click listener

    Every time user clicks on the button, the 'LoginFragment' creates a new observer, while previous observers still alive and kicking. This is the reason why every time you try login, the number of emission increases by one. Also, this design is vulnerable to screen rotations and configuration changes. For instance, imagine the user rotates the screen during the login request. The result of the that login request is lost because nothing is observing that request, and the worst part is that the view will show that the user is not logged in, while repository's point of view the user is.

    In order to fix this properly you need to separate observation logic and click event logic. Also remember to always observe in onCreateView() or onActivityCreated() unless you pass-in custom LifeCycleOwner object or remove observer.


    [2] Incorrect usage of switchMap in view model

    Another problem is that every time viewModel.generalLogin() is called, another switchMap is used and consequently a whole new LiveData is created. LiveData is not something you should create dynamically. They need to be created once at the view model initialization and observed until the view model is cleared.


    [3] Repository

    The repository code is mostly justifiable, but I think it could be improved by making generalLogin not to return a LiveData. Just go back to call back kind of style or just don't return anything at all. Also note that the current repository has loginMutableData. While this is okay, that's one more variable you manually need to keep track of. You generally want to keep the repository stateless if possible.

    So the full solution, repository to fragment:

    Repository

    // Create this new function
    fun getLoginStatus(): LiveData<SingleLiveEvent<Resource<UserSession>>> {
        return loginMutableData
    }
    
    // Notice this function does not return anything.
    fun generalLogin(email: String, encryptedPassword: String) {
    
        val login = Global.network.login(email, encryptedPassword)
    
        login.enqueue(object : Callback<LoginResponse> {
    
            override fun onResponse(call: Call<LoginResponse>, response: Response<LoginResponse>) {
                ...
            }
    
            override fun onFailure(call: Call<LoginResponse>, t: Throwable) {
                ...
            }
    
        })
    }
    

    ViewModel

    val userSessionLiveData: LiveData<SingleLiveEvent<Resource<UserSession>>>
    // val userSessionLiveData: LiveData<Resource<UserSession>>
    
    init {
    
        userSessionLiveData = loginRepository.getLoginStatus()
    
        // Use below if some mapping needs to be done
        // userSessionLiveData = Transformations.map(loginRepository.getLoginStatus()) {
        //     return it?.contentIfNotHandled?
        // }
    }
    
    fun generalLogin(email: String, password: String) {
        val encryptedPassword = MCryptHelper.bytesToHex(MCryptHelper().encrypt(password))
        loginRepository.generalLogin(email, encryptedPassword)
    }
    

    Fragment

    void onCreateView(...): View {
        ...
        viewModel.userSEssionLiveData.observe(viewLifecycleOwner, Observer {
    
            val resource = it?.contentIfNotHandled?
            if (resource == null) return
    
            val session = resource.data?
    
            if (resource.status) {
                Toast.makeText(
                    context,
                    "Hi, " + session.displayName,
                    Toast.LENGTH_SHORT
                ).show()
    
                val sharedPref = PreferenceManager
                    .getDefaultSharedPreferences(context)
                val editor = sharedPref.edit()
    
                editor.putString(getString(R.string.user_id), session.email).apply()
                editor.putString(getString(R.string.user_name), session.email).apply()
    
                activity?.finish()
    
            } else {
                Toast.makeText(
                    context,
                    "Error, " + resource.message,
                    Toast.LENGTH_SHORT
                ).show()
            }
        }
        ...
    }
    
    private fun onClickLogin(view: View) {
    
        view.loginButton.setOnClickListener {
    
            val emailAddress = view.emailTextInputEditText.text.toString()
            val password = view.passwordTextInputEditText.text.toString()
    
            viewmodel.generalLogin(emailAddress, password)
        }
    }