The bounty expires in 12 hours. Answers to this question are eligible for a +200 reputation bounty.
martin1337 is looking for a canonical answer:
Need to fix this asap - I cant simulate it by myself by any means. I just got this log from one of many users who are affected by this bug.
I have interesting issue with my app for some time now. Unfortunately I personally cant simulate this behavior on any of my phones but there are some users who are affected by this.
The issue is that I have Fragment which can be started only by pressing button by user. And this fragment is removed from stack after user exits it.
Fragment is used to open WebView with payment gateway to handle transactions (paying by credit card for items).
What is happening to some users is that if they leave phone and app in background (screen is locked and they are not using phone at all), sometimes when phone wakes up, transaction from like 2 days ago is executed without their knowledge. It will just randomly start that Fragment which was used 2 days ago without any user interaction.
It seems like Fragment just popped without calling constructor with data out of nowhere. The only way how to start this fragment again is to push button "Buy Item" on specific screen. This screen is not even there when user unlocked that phone.
I cant simulate this behavior but its happening to some users and its annoying.
Anyone knows what can be a cause of this and how to prevent that?
Code:
startFragmentForResult(
PurchaseFragment(provider, id, sum.first, sum.second, discountId,
validFrom = validFromDateIso, duration = duration),
object : FragmentResult<Boolean>() {
override fun onFragmentResult(result: Boolean) {
App.log("PurchaseDis: BasketFragment - onFragmentResult - enable back")
mainActivity?.onBackEnabled(true)
removeThis{
App.log("FragmentTest - BasketFragment - onFragmentResult - after remove")
mainActivity?.refreshData()
}
}
})
}
fun startFragmentForResult(f: BaseFragment, receiver: FragmentResult<*>, hidePrevFragment: Boolean = true){
val isStarting = mainActivity?.isFragmentStarting?:false
if (!isStarting) {
mainActivity?.isFragmentStarting = true
fragmentStarting = true
app.sysLog("startFragmentForResult ($debugTitle) -> (${f.debugTitle})")
mainActivity?.startFragmentForResult(f, receiver, hidePrevFragment = hidePrevFragment)
}else if(BuildConfig.DEBUG) App.log("Prevent multiple fragment start")
}
private var fragmentResultReceivers = HashMap<BaseFragment, BaseFragment.FragmentResult<*>>()
fun startFragmentForResult(f: BaseFragment, receiver: BaseFragment.FragmentResult<*>, hidePrevFragment: Boolean = true) {
fragmentResultReceivers[f] = receiver
startFragment(f, hidePrevFragment = hidePrevFragment)
}
fun startFragment(
f: BaseFragment,
allowGoBack: Boolean = true,
hidePrevFragment: Boolean = true) {
App.log("MainActivity: startFragment: ${f.title}")
app.sysLog("startFragment (${f.debugTitle})")
App.log("FragmentArray: ${getFragments().size}")
makeFragmnentTransaction { ft ->
ft.setCustomAnimations(f.animIn, R.animator.fade_out)
val tag = f.javaClass.simpleName
if (allowGoBack && (f.isModal || f.canGoBack)) {
if (hidePrevFragment){
// hide previous fragment
getTopFragment()?.let {
ft.hide(it)
it.onHide()
}
}
ft.add(R.id.content, f, tag)
} else {
fragmentResultReceivers.values.forEach { it.onFragmentResultCanceled() }
fragmentResultReceivers.clear()
ft.replace(R.id.content, f, tag)
}
ft.runOnCommit {
updateUI(f)
}
}
}
private fun makeFragmnentTransaction(body: (FragmentTransaction) -> Unit) {
with(supportFragmentManager.beginTransaction()) {
body(this)
commitAllowingStateLoss()
}
}
fun removeFragment(f: BaseFragment, initDefaultTabIfEmpty: Boolean = true, onCommit: (() -> Unit)? = null) {
app.sysLog("removeFragment (${f.debugTitle})")
makeFragmnentTransaction { ft ->
ft.setCustomAnimations(R.animator.fade_in, f.animOut)
ft.remove(f)
val frgs = getFragments()
val fI = frgs.indexOf(f)
if (fI == frgs.lastIndex && fI > 0) {
// show previous fragment again - only if removed fragment is last one
val af = frgs[fI - 1]
app.sysLog("onShowAgain (${af.debugTitle})")
af.onShowAgain()
ft.show(af)
}
ft.setReorderingAllowed(false)
ft.runOnCommit {
afterFragmentRemoval(f, initDefaultTabIfEmpty)
onCommit?.invoke()
}
}
}
private fun getFragments(): List<BaseFragment> {
return ArrayList<BaseFragment>().apply {
for (e in supportFragmentManager.fragments) {
if (e is BaseFragment)
add(e)
}
}
}
fun getTopFragment(): BaseFragment? = getFragments().lastOrNull()
Maybe worth mentioning. I have data which phones are causing this issue:
"deviceBrand": "samsung",
"deviceModel": "SM-G991B",
"systemSdkInt": 33,
"deviceBrand": "samsung",
"deviceModel": "SM-G990B",
"systemSdkInt": 33,
"deviceBrand": "samsung",
"deviceModel": "SM-G970F",
"systemSdkInt": 31,
All of those devices are Samsung and all of them are Android 11+
UPDATE:
I have new logs from user which is affected by it:
2023/01/13 15:11:59: Purchase successful
2023/01/13 15:11:59: returnFragmentResult (Purchase)
2023/01/13 15:11:59: removeFragment (Purchase)
2023/01/13 15:11:59: onShowAgain (Basket)
2023/01/13 15:11:59: removeFragment (Basket)
2023/01/13 15:11:59: onShowAgain (Tickets)
2023/01/13 15:11:59: FragmentManagerListener: onFragmentDestroyed: Purchase
2023/01/13 15:11:59: FragmentManagerListener: onFragmentDetached: Purchase
2023/01/13 15:12:00: FragmentManagerListener: onFragmentDestroyed: Basket
2023/01/13 15:12:00: FragmentManagerListener: onFragmentDetached: Basket
2023/01/14 18:38:59: App create
2023/01/14 18:39:00: PurchaseFragment.onCreate, args=Bundle[{duration=0, tickets={"tickets":[{"ticket_id":5443,"qty":4}]}, org_id=1208, provider=52, valid_from=null, price=4.23, currency=€}]
2023/01/14 18:39:00: Buy tickets: {"tickets":[{"ticket_id":5443,"qty":4}]}
2023/01/14 18:39:00: proceedWithPreorderId: 9857788, transact: null
2023/01/14 18:39:00: Wait for preorder...
2023/01/14 18:39:08: Purchase successful
I added log to supportFragmentManager listener to log state of each fragment in app. And it seems like after successful purchase, fragments are successfully detached and destroyed in manager but 1 day later as user wakes up phone and app, MainActivity is recreated (onCreate is called "App create") and 1 second later constructor of fragment is randomly out of nowhere called by the system. Even Fragment Manager listener is not detecting that fragment.
Not sure what can be wrong here.
Worth to mention that I've added to MainActivity function to clear backstack every time MainActivity is recreated to remove any fragments hanging there (if there are some):
override fun onCreate(si: Bundle?) {
clearFragmentBackstack()
isRecreated = si != null
if (si == null){
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition{ !areDataLoaded }
} else {
App.log("MainActivity: SavedStateTest: onCreate: recreated")
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition{ false }
}
super.onCreate(si)
...
}
private fun clearFragmentBackstack(){
if (supportFragmentManager.backStackEntryCount > 0) {
val first =supportFragmentManager.getBackStackEntryAt(0)
supportFragmentManager.popBackStack(first.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
}
when the activity is recreated again, it's normal that the last fragment was in the activity to be recreated but in your situation, it's a payment fragment that shouldn't be started by itself.
so to prevent such scenario.
You can create another status fragment and after payment is done, remove payment fragment from the stack and navigate to the status fragment.
also as we do in our apps. when the user initiates a payment, we are saving user's click time in shared preferences (datastore recently) and before making the payment we are checking for the user's click time, and if it was more than 5 minutes ago, we are not proceeding with the payment.
Related
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..
Basically I have a simple code that sends multiple events:
for (i in 1..2) {
val bundle = Bundle().apply {
putInt("step", i)
putString("key", "value")
}
mFirebaseAnalytics.logEvent("test_event", bundle)
}
Despite the fact that events are logged separately, sometimes they are displayed doubled (or combined) in DebugView, so it looks like this
But sometimes events are shown separately (as normal).
Any ideas why this happen? Logcat shows the events are being sent separately.
P.S. The real case scenario when I noticed this behavior is tracking the viewed products in the list (RecyclerView), when the user quickly scrolls the list and the events are doubled in the DebugView.
Seems Firebase hasn't addressed this issue cause I'm having it as well. I was able to work around the issue in two different ways.
Add a delay at the bottom of the for loop between each item. NOT PREFERRED
for (i in 1..2) {
val bundle = Bundle().apply {
putInt("step", i)
putString("key", "value")
}
mFirebaseAnalytics.logEvent("test_event", bundle)
// Add minor sleep, wait, setTimeout.
}
Call another event at the end of the for loop so it splits your "test_event" calls.
for (i in 1..2) {
val bundle = Bundle().apply {
putInt("step", i)
putString("key", "value")
}
mFirebaseAnalytics.logEvent("test_event", bundle)
val bundle2 = Bundle().apply {
putString("EventCategory", "relevant category")
putString("EventAction", "relevant action")
putString("EventLabel", "relevant label")
}
mFirebaseAnalytics.logEvent("all_events", bundle)
}
FYI these code snippets don't run without error. I just copied the questioners code since I'm not sure what language he's using.
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.
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.
I have an activity which hosts two fragments with only one shown at a time. Effectively the user, through different environmental conditions, should be able to toggle between the two at any given time.
There is a LoginFragment which is the first thing the user sees on login, and a LockoutFragment which may replace the LoginFragment after a user logs in and we see their account is locked (naturally).
That is the typical case, but there is a case in which LockoutFragment is presented first, if say, the user is using the app and their account is locked for some reason, and we re-open the host activity (LoginActivity), showing the LockoutFragment, but giving them a button to "Return to login", which toggles appearance of the LoginFragment (also naturally).
Thus, my goal is to allow a user to toggle between the two fragments, whichever is displayed first. My host activity uses the following functions to achieve this effect:
private void showLockoutFragment() {
if (mLockoutFragment == null) {
mLockoutFragment = new LockoutFragment();
}
transitionToFragment(FRAGMENT_LOCKOUT, mLockoutFragment);
}
private void showLoginFragment() {
if (mLoginFragment == null) {
mLoginFragment = new LoginFragment();
}
transitionToFragment(FRAGMENT_LOGIN, mLoginFragment);
}
private void transitionToFragment(String transactionTag, Fragment fragment) {
if (!getFragmentManager().popBackStackImmediate(transactionTag, 0)) {
FragmentTransaction ft = getFragmentManager().beginTransaction();
ft.setCustomAnimations(
R.animator.fade_in, R.animator.fade_out,
R.animator.fade_in, R.animator.fade_out);
ft.addToBackStack(transactionTag);
ft.replace(R.id.fragment_container, fragment, transactionTag);
ft.commit();
}
}
#Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// non configuration change launch
if (savedInstanceState == null) {
Bundle extras = getIntent().getExtras();
if (extras != null) {
// decide which fragment to show
boolean shouldLockout = extras.getBoolean(EXTRA_SHOULD_LOCKOUT);
if (shouldLockout) {
showLockoutFragment();
} else {
showLoginFragment();
}
} else {
showLoginFragment();
}
} else {
// retrieve any pre-existing fragments
mLoginFragment = (LoginFragment)getFragmentManager().findFragmentByTag(FRAGMENT_LOGIN);
mLockoutFragment = (LockoutFragment)getFragmentManager().findFragmentByTag(FRAGMENT_LOCKOUT);
}
}
These functions work together like a charm, with one exception: when, after initial launch of the app, a user
attempts log in,
is taken to the lockout fragment,
reorients the device, and
navigates back to the login fragment,
the login fragment is now present but invisible - as if the popEnter animation was never played. I know it is present because I can still interact with it.
It is also worth noting the following:
I have setRetainInstance(true) on both fragments
This only occurs when a user reorients the device from the lockout fragment
I have tried this on both a simulator and device running Lollipop with same results
Is it possible that the back stack is being corrupted after reorientation?
Thank you!
Ok, so it turns out the issue actually lies in my use of setRetainInstance. According to the docs for that method:
Control whether a fragment instance is retained across Activity re-creation (such as from a configuration change). This can only be used with fragments not in the back stack. [emphasis mine]
While this appears rather cryptic to me, it seems that using setRetainInstance(true) on a fragment that is on the back stack could simply have unintended consequences. In my case, the fragment seemed to be retained, but its popEnter animation was never being called (post-rotation). Again, weird, but I guess just avoid that combination.