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.
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)
}
}