Question:
How can I prevent my livedata immediately receiving stale data when navigating backwards? I am using the Event class outlined here which I thought would prevent this.
Problem:
I open the app with a login fragment, and navigate to a registration fragment when a live data email/password is set (and backend call says "this is a new account go register"). If the user hits the back button during the registration Android is popping back to login.
When the login fragment is recreated after a back press, it immediately fires the live data again with the stale backend response and I would like to prevent that.
LoginFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
subscribeToLoginEvent()
}
private fun subscribeToLoginEvent() {
//When a back press occurs, we subscribe again and this instantly
//fires with the same data it used to leave the screen
//(a Resource<User> with status=SUCCESS, data = null)
viewModel.user.observe(viewLifecycleOwner, Observer { response ->
Timber.i("login event observed....status:" + response?.status + ", data: " + response?.data)
binding.userResource = response
response?.let {
val status = it.status
val message = it.message
if (status == Status.SUCCESS && it.data == null) {
//This is a brand new user so we need to register now
navController()
.navigate(LoginFragmentDirections.showUserRegistration()))
}
else if(status == Status.SUCCESS && it.data != null){
goHome()
}
}
})
}
LoginViewModel.kt
private val _loginCredentials: MutableLiveData<Event<Pair<String, String>>> = MutableLiveData()
val user: LiveData<Resource<User>> = Transformations.switchMap(_loginCredentials) {
val data = it.getContentIfNotHandled()
if(data != null && data.first.isNotBlank() && data.second.isNotBlank())
interactor.callUserLoginRepo(data.first, data.second)
else
AbsentLiveData.create()
}
Okay there's two issues here which I hope helps somebody else.
The first is that the Event class does not appear to work with transformations. I think it is because the Event is literally pointing to the wrong live data (_login_credentials vs user)
The second problem is a little bit more fundamental but also blindingly obvious now. We are told all over the place that live data observations emit the latest data when a subscription is made to ensure you get the most up to date data. This means the way I am using live data here is simply incorrect, I can't subscribe to a login event, navigate somewhere, navigate back and re-subscribe because the ViewModel is rightfully giving me the latest data it has (because the login fragment was only detached, never destroyed).
Solution
The solution is to simply move the logic which performs the fetch one fragment deeper. So instead of listening for user credentials + login click -> fetching a user -> and then navigating somewhere, I need to listen for user credentials + login click -> navigate somewhere -> and then start subscribing for my user live data. That way I can head back to the login screen as much as I want and not subscribe to some stale live data that was never destroyed. And if I go back to login and then forwards the subscription and fragment were destroyed so I will appropriately be getting new data in that case.
Related
I would like to put a check to ensure that if they are already present it does nothing, otherwise it adds the new ones.
this is the code i wrote:
private fun setUpFireStore() {
firestore = FirebaseFirestore.getInstance()
val collectionReference = firestore.collection("quiz")
collectionReference.addSnapshotListener { value, error ->
if(value == null || error != null){
Toast.makeText(this, "Error fetching data", Toast.LENGTH_SHORT).show()
return#addSnapshotListener
}
Log.d("DATA", value.toObjects(Quiz::class.java).toString())
quizList.clear()
quizList.addAll(value.toObjects(Quiz::class.java))
adapter.notifyDataSetChanged()
}
}
every time I go back to the home, this function is called and adds again all the quizzes present in the Firestore db.
The colors and images are randomly generated each time they are added. (correct operation when starting the app) but if you keep refreshing the home screen, even if they are the same they keep changing...
(identification field: "title")
So it seemed more logical to me that quizzes are only added if they are missing on refresh, not every time this screen is reopened.
but i can't figure out how to implement this function
I have been facing this issue for quite sometime and would like to know a better approach to solve this problem. If you are aware of anything about how to solve it then please let me know.
I am building a project which takes data from an API and then following MVVM architecture I take the Retrofit instance to Repository, and then to ViewModel and further observe it from my fragment.
Now what I am working on currently is Login feature. I will send a number to the API and in response I will receive if the number is registered or not. If it is registered then I would move to the next screen.
Now the problem is that using one of the function in ViewModel I send that number to the API to get the response. And using a variable I observe that response.
Now I create a function which checks if the response was true or false and based on that I am using the logic to move to the next screen, but the issue is the returned value from the function. As LiveData works asynchronously in background it takes some time to return the value and in meantime the function returns the initial value which is false.
Function to verify response
private fun checkNumber(): Boolean {
var valid = false
authRiderViewModel.response.observe(viewLifecycleOwner, Observer {
Timber.d("Response: $it")
if (it.success == true) {
valid = true
}
})
Timber.d("Boolean: $valid")
return valid
}
Moving to next screen code:
binding.btnContinue.setOnClickListener {
val number = binding.etMobileNumber.text.toString().toLong()
Timber.d("Number: $number")
authRiderViewModel.authDriver(number)
if (checkNumber()) {
val action = LoginFragmentDirections.actionLoginFragmentToOtpFragment()
findNavController().navigate(action)
} else {
Toast.makeText(requireContext(), "Number not registered", Toast.LENGTH_SHORT).show()
}
}
So in case I received the true response from the server even then I would not move to the next screen because the initial value I received is false. I have spent few hours trying to fix it and any help would be appreciated. If you need any code let me know in comments. Thanks.
You have four distinct states:
The server returned a positive response
The server returned a negative response
The server failed (e.g., returned a 500, timed out)
You are waiting on the server
You are attempting to model that with two states: true and false. This will not work.
Instead, model it with four states. One approach is called "loading-content-error" and uses a sealed class to represent those states:
sealed class LoginState {
object Loading : LoginState()
data class Content(val isSuccess: Boolean) : LoginState()
object Error : LoginState()
}
Your LiveData (or your StateFlow, once you migrate to coroutines) would be a LiveData<LoginState>. Your observer can then use a when to handle Loading, Content, and Error as needed, such as:
For Loading, display a progress indicator
For Content, do whatever you are doing now with your boolean
For Error, display an error message
Actually, live data observation is an asynchronous operation. You have to code accordingly.
Just calling checkNumber() won't return since is asynchronous instead I give you some ideas to implement in a better way.
Just call the checkNumber when button click inside the check number do this instead of return valid
authRiderViewModel.response.observe(viewLifecycleOwner, Observer {
Timber.d("Response: $it")
if (it.success == true) {
val action = LoginFragmentDirections.actionLoginFragmentToOtpFragment()
findNavController().navigate(action)
} else {
Toast.makeText(requireContext(), "Number not registered", Toast.LENGTH_SHORT).show()
}
})
I want to create a shared view model for communication between MainActivity to fragments.
I decided to use share flow for managing events.
private val _sharedChannel: MutableSharedFlow<SharedEvent> = MutableSharedFlow(
replay = 0,extraBufferCapacity=0,onBufferOverflow = BufferOverflow.SUSPEND)
val sharedChannel = _sharedChannel.asSharedFlow()
I don't need to cache the last event, not even when orientation changes.. so I set "replay = 0"
When I collect the events only in my main activity - everything works fine:
lifecycleScope.launchWhenStarted {
gamePlaySharedViewModel.sharedChannel.collect { event->
SnappLog.log("GamePlayContainer-> sharedChannel EVENT: $event ")
when(event){
GamePlaySharedViewModel.SharedEvent.OnBackPress -> {
onBackPressed()
}
is GamePlaySharedViewModel.SharedEvent.BlockScreen -> {
blockScreen(event.isBlocked)
}
else -> {
}
}
}
}
}
When adding a second subscriber to another fragment - both of the subscribers stop receiving events after the first one (the first event send successfully.. )
what can I do to subscribe for multi MutableSharedFlow?
I've tried to increase the number of "replay" and to change the "onBufferOverflow" - nothing seems to work..
I'm trying to implement back button handling on Android using CoRedux for my Redux store. I did find one way to do it, but I am hoping there is a more elegant solution because mine seems like a hack.
Problem
At the heart of the problem is the fact returning to an Android Fragment is not the same as rendering that Fragment for the first time.
The first time a user visits the Fragment, I render it with the FragmentManager as a transaction, adding a back stack entry for the "main" screen
fragmentManager?.beginTransaction()
?.add(R.id.myFragmentContainer, MyFragment1())
?.addToBackStack("main")?.commit()
When the user returns to that fragment from another fragment, the way to get back to it is to pop the back stack:
fragmentManager?.popBackStack()
This seems to conflict with Redux principles wherein the state should be enough to render the UI but in this case the path TO the state also matters.
Hack Solution
I'm hoping someone can improve on this solution, but I managed to solve this problem by introducing some state that resides outside of Redux, a boolean called skipRendering. You could call this "ephemeral" state perhaps. Initialized to false, skipRendering gets set to true when the user taps the back button:
fun popBackStack() {
fragmentManager?.popBackStack()
mapViewModel.dispatchAction(MapViewModel.ReduxAction.BackButton)
skipRendering = true
}
Dispatching the back button to the redux store rewinds the redux state to the prior state as follows:
return when (action) {
// ...
ReduxAction.BackButton -> {
state.pastState
?: throw IllegalStateException("More back taps processed than past state frames")
}
}
For what it's worth, pastState gets populated by the reducer whenever the user requests to visit a fragment from which the user can subsequently tap back.
return when (action) {
// ...
ReduxAction.ShowMyFragment1 -> {
state.copy(pastState = state, screenDisplayed = C)
}
}
Finally, the render skips processing if skipRendering since the necessary work of calling fragmentManager?.popBackStack() was handled before dispatching the BackButton action.
I suspect there is a better solution which uses Redux constructs for example a side effect. But I'm stuck figuring out a way to solve this more elegantly.
To solve this problem, I decided to accept that the conflict cannot be resolved directly. The conflict is between Redux and Android's native back button handling because Redux needs to be master of the state but Android holds the back stack information. Recognizing that these two don't mix well, I decided to ditch Android's back stack handling and implement it entirely on my Redux store.
data class LLReduxState(
// ...
val screenBackStack: List<ScreenDisplayed> = listOf(ScreenDisplayed.MainScreen)
)
sealed class ScreenDisplayed {
object MainScreen : ScreenDisplayed()
object AScreen : ScreenDisplayed()
object BScreen : ScreenDisplayed()
object CScreen : ScreenDisplayed()
}
Here's what the reducer looks like:
private fun reducer(state: LLReduxState, action: ReduxAction): LLReduxState {
return when (action) {
// ...
ReduxAction.BackButton -> {
state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
it.addAll(state.screenBackStack)
it.removeAt(0)
})
}
ReduxAction.AButton -> {
state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
it.add(ScreenDisplayed.AScreen)
it.addAll(state.screenBackStack)
})
}
ReduxAction.BButton -> {
state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
it.add(ScreenDisplayed.BScreen)
it.addAll(state.screenBackStack)
})
}
ReduxAction.CButton -> {
state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
it.add(ScreenDisplayed.CScreen)
it.addAll(state.screenBackStack)
})
}
}
}
In my fragment, the Activity can call this API I exposed when the Activity's onBackPressed() gets called by the operating system:
fun popBackStack() {
mapViewModel.dispatchAction(MapViewModel.ReduxAction.BackButton)
}
Lastly, the Fragment renders as follows:
private fun render(state: LLReduxState) {
// ...
if (ScreenDisplayed.AScreen == state.screenBackStack[0]) {
fragmentManager?.beginTransaction()
?.replace(R.id.llNavigationFragmentContainer, AFragment())
?.commit()
}
if (ScreenDisplayed.BScreen == state.screenBackStack[0]) {
fragmentManager?.beginTransaction()
?.replace(R.id.llNavigationFragmentContainer, BFragment())
?.commit()
}
if (ScreenDisplayed.CScreen == state.screenBackStack[0]) {
fragmentManager?.beginTransaction()
?.replace(R.id.llNavigationFragmentContainer, CFragment())
?.commit()
}
}
This solution works perfectly for back button handling because it applies Redux in the way it was meant to be applied. As evidence, I was able to write automation tests which mock the back stack as follows by setting the initial state to one with the deepest back stack:
LLReduxState(
screenBackStack = listOf(
ScreenDisplayed.CScreen,
ScreenDisplayed.BScreen,
ScreenDisplayed.AScreen,
ScreenDisplayed.MainScreen
)
)
I've left some details out which are specific to CoRedux.
I'm currently having an issue during some of my users who log-in with email and password. The case is that after send the verification email, I'm calling reload in my current FirebaseUser to update the currentUser an know if the email have been verified or not.
Everything works fine at this point. The thing is that sometimes after call reload over an instance of FirebaseAuth.currentUser.reload(), on the successful callback of reload I'm trying to access again to the already updated FirebaseAuth.currentUser instance, and the funny thing is that this comes to be null and I'm getting a NPE, when the user have been successfully reloaded and the instance should be updated, not deleted.
This is my code right now:
override fun verifyUser() {
if (authInstance.currentUser == null) {
dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(requestState = requestFailure(FirebaseUserNotFound()),
verified = false))
return
}
RxFirebaseUser.reload(authInstance.currentUser!!)
.subscribeOn(Schedulers.io())
.subscribe({
dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(requestState = requestSuccess(),
verified = authInstance.currentUser!!.isEmailVerified))
}, { error ->
dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(requestState = requestFailure(error),
verified = false))
})
}
The RxFirebase call is a Reactive wrapper over Firebase, but I have tried this code also using the normal Promise of the library and this error is happening too.
My NPE is coming on verified= authInstance.currentUser!!.isEmailVerified which should not be possible because I already did a check before start this call and the reload call have been successful.
Someone have suffer this issue and know why this could be happening? How can be the workaround for it? I could control the NPE there, but if the reload have been successful I want to update my user in my app data, not send a null instance.
The authInstance.currentUser sometimes takes some time to be updated. You can move your reload call to the main thread on the observeOn to give authInstance more time to be updated, and also just in case add a retry clause to your code. It would be something like this:
RxFirebaseUser.reload(authInstance.currentUser!!)
.subscribeOn(Schedulers.io())
.repeatUntil { authInstance.currentUser != null }
.defaultTimeout()
.subscribe({
val user = authInstance.currentUser
val emailVerified = user?.isEmailVerified ?: false
dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(requestState = if (user == null) requestRunning() else requestSuccess(),
verified = emailVerified))
}, { error ->
dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(requestState = requestFailure(error),
verified = false))
})