skip to Main Content

I’ve built a game app and it uses Cloud Firestore to store user data locally.

The application’s business logic is very simple regarding the data persistence methodology. Whenever a user does something with his data, I simply save his data, whenever any activity calls onPause() I also save his data.

I only use Source.CACHE as a Source parameter in every single saving procedure.
At this state of development cloud data persistence is not important, so as of right now I’m only interested in offline, device-level data persistence.

So in short: Every save method saves user data to the device.

About the error:

I’ve tested the application with 50 users for six months and never seen this issue happening, but when I decided to go open beta a few users started to report the very same issue, which is:

The application seemingly rolls back to old data state when the user reopens the app.

I would like to show you the save and load methods so you can see there is no rocket science here, I’m trying to achieve something very simple. Whenever the user does something which alters his data state, I would call the save method. Whenever the user opens the application, I would load back his data with my load method.

Loading method:

//I use this class to load a character
public class CharacterLoader implements FetcherInterface {

    String documentId; //The document id of the character
    String accountId; //The account id of the user
    Source source = Source.CACHE; //Always, and always Source.CACHE. Never loading from the cloud.

    //My very simple constructor
    public CharacterLoader(String accountId, String documentId) {
        this.documentId = documentId;
        this.accountId = accountId;
    }

    //This method basically launch the loading procedure
    @Override
    public void go(DbResultListener dbResultListener) {

        FirebaseFirestore db = FirebaseFirestore.getInstance();

        //Building the query to fetch the correct character by its documentId
        Query query = db.collectionGroup("localData").whereEqualTo("documentId", documentId);
        query.get(this.source).addOnSuccessListener(new OnSuccessListener<QuerySnapshot>() {
            @Override
            public void onSuccess(QuerySnapshot queryDocumentSnapshots) {

                //If the loading was successful but there is no such character
                if (queryDocumentSnapshots.getDocuments().size() == 0) {
                    dbResultListener.onError("Error - There was an error during loading the character. Please try again.");
                } else {
                    //Loading was successful
                    DocumentSnapshot documentSnapshot = queryDocumentSnapshots.getDocuments().get(0);
                    Chara chara = documentSnapshot.toObject(Chara.class);
                    if (chara == null) {
                        //Character was null for some reason
                        dbResultListener.onError("Error - Character not found!");
                    } else {
                        //If the character found, I pass it to a listener callback so my app can use it
                        dbResultListener.onSuccess(chara);
                    }
                }
            }
        }).addOnFailureListener(new OnFailureListener() {
            @Override
            public void onFailure(@NonNull Exception e) {
                //If ther was an error
                e.printStackTrace();
                dbResultListener.onError("Error on character loading: "+e.getMessage());
            }
        });
    }
}

Saving method:

//I use this class to save character
public class CharacterSaver implements FetcherInterface {

    Chara chara; //The data entity which we save
    String accountId; //User's account id
    String baseLink; //Link/path to my Cloud Firestore storage

    //A very simple constructor, it stops the app with a runtime error if the data parameter is null.
    public CharacterSaver(String baseLink, String accountId, Chara chara) {
        if (chara == null) {
            throw new RuntimeException("Chara object was null in parameter, cannot save!");
        }
        this.accountId = accountId;
        this.chara = chara;
        this.baseLink = baseLink;
    }

    //This is my save method
    @Override
    public void go(DbResultListener dbResultListener) {

        FirebaseFirestore db = FirebaseFirestore.getInstance();

        //Getting the document reference on the correct path with character's document id
        DocumentReference docRef = db.collection(baseLink).document(accountId).collection("localData").document(chara.getDocumentId());
        docRef.set(chara).addOnSuccessListener(new OnSuccessListener<Void>() {
            @Override
            public void onSuccess(Void aVoid) {
                //Saving was successful, noting to do.
            }
        }).addOnFailureListener(new OnFailureListener() {
            @Override
            public void onFailure(Exception e) {
                e.printStackTrace();
                //Passing the error with a listener to the application
                dbResultListener.onError("There was an error during saving character: " + e.getLocalizedMessage());
            }
        });
    }
}

And now please watch the video I have about this error. A very kind and eager user successfully made a screen recording about the exact moment this bug occurs, please watch the video with sound because he narrates what is going on:

You can watch the error explaining video here.

Watch closely, whenever the user exits the application in the regular way, everything is just fine, but whenever he uses the "app history/go to background" button of his device, then reopens the application the rollback occurs!

My thoughts about the issue:

So let’s assume I made a mistake and I do not call my save method everywhere where I should, and let’s assume I have a data state loss. Well that wouldn’t explain the issue, because as you see in the video the application goes back to an OLDER STATE after it had a CORRECT, up to date state.

So it is not just a missed saving, thus state loss, it is definitely a ROLLBACK.

I have absolutely no clue about how is even this suppose to happen, I couldn’t even write this even if I’ve wanted to. There is absolutely nothing in my business logic which would justify this behavior, no method for rollbacks. Just a simple save-load methodology.

What I’ve tried so far:

  • Logged through the whole application with Firebase Crashalytics, looking for null pointer exceptions, empty data states.
  • Logged the onError() callbacks, looking for something, anything which would tell me more about this.
  • Logged version numbers for characters, watching closely whether a load is loading back an older character somehow.
  • Logged date/time of saves/loads.
  • Added a load method directly after my save method to check if I do get the correct, up to date data or not.
  • Made a copy constructor for my data (Chara class) maybe the error somehow (I don’t know how) was caused by passing the reference instead of building a copy of the data.
  • Tried to replicate the error with all of the above while having internet access and also while not having internet access.
  • Tried to replicate the error on: Xiaomi Mi A1, Pixel 7, Samsung A04, and on various emulators with various Android versions, I just couldn’t.

The annoying thing is, some users have hundreds of hours in the game and they never once experienced this, while others have this issue like daily.

I just don’t know where to search more for solution, I’ve read the whole internet seeking for an answer, I don’t even know how could this happen since I do nothing complicated here. One simple save method for every user interaction and one load method when the application starts, so please… Please if you have any insight about this, don’t hold it back.

Thanks in advance.

E D I T:

As requested, here is my onPause() method, I do not override the onDestroy() method, nothing extraordinary here as well:

  @Override
    protected void onPause() {
    super.onPause();
    CharacterSaversaver saver = new CharacterSaver(Server.getUsersUrl(this), Identifiers.getAccountIdentifier(this), getChara());
        
    saver.go(new DbResultListener() {
        @Override
        public void onError(String error) {
            DialogHelper.show(BaseActivity.this, error);
            }
        });
    }

The above mentioned saving class simply gets the character instance from static memory like this:

public static Chara getChara() {
        return charaPlayingStaticInstance;
    }

2

Answers


  1. While the device is offline, if you have enabled offline persistence, your listeners will receive listen events when the locally cached data changes. You can listen to documents, collections, and queries.

    To check whether you’re receiving data from the server or the cache, use the fromCache property on the SnapshotMetadata in your snapshot event. If fromCache is true, the data came from the cache and might be stale or incomplete. If fromCache is false, the data is complete and current with the latest updates on the server.

    By default, no event is raised if only the SnapshotMetadata changed. If you rely on the fromCache values, specify the includeMetadataChanges listen option when you attach your listen handler.

    source

    Please see the parts with BOLD, firestore will ay consider your changes on cache as incomplete or outdated, so you should have a snapshot listener with MetadataChanges.INCLUDE so the changes on the cache would be considered as new values.

    so your code should look like something like this, (this is just a code snippet) :

    db.collection("localData").whereEqualTo("documentId", documentId)
            .addSnapshotListener(MetadataChanges.INCLUDE, new EventListener<QuerySnapshot>() {
                @Override
                public void onEvent(@Nullable QuerySnapshot querySnapshot,
                                    @Nullable FirebaseFirestoreException e) {
                    if (e != null) {
                        Log.w(TAG, "Error", e);
                        return;
                    }
    
                    for (DocumentChange change : querySnapshot.getDocumentChanges()) {
                        if (change.getType() == Type.ADDED) {
                            Log.d(TAG, "Result: " + change.getDocument().getData());
                        }
    
                        String source = querySnapshot.getMetadata().isFromCache() ?
                                "Cache" : "Online";
                        Log.d(TAG, "Data Source: " + source);
                    }
    
                }
            });
    

    UPDATE: Firestore will use Snapshot by default if you have offline caching:

    By default, a get call will attempt to fetch the latest document snapshot from your database. On platforms with offline support, the client library will use the offline cache if the network is unavailable or if the request times out.

    Login or Signup to reply.
  2. I might be wrong but this issue could be related to the Android activity lifecycle and how you handle the saving process in the onPause() method.

    Firstly, you can try saving data in the onPause() method, consider using the onStop() method.
    For example –

    @Override
    protected void onStop() {
        super.onStop();
        CharacterSaver saver = new CharacterSaver(Server.getUsersUrl(this), Identifiers.getAccountIdentifier(this), getChara());
    
        saver.go(new DbResultListener() {
            @Override
            public void onError(String error) {
                DialogHelper.show(BaseActivity.this, error);
            }
        });
    }
    

    Secondly, in onStop(), you can check whether the activity is finishing before saving the data. If the activity is finishing, it means the user is navigating away from the activity, and it’s a suitable time to save data.
    For example –

    @Override
    protected void onStop() {
        super.onStop();
        if (!isFinishing()) {
            CharacterSaver saver = new CharacterSaver(Server.getUsersUrl(this), Identifiers.getAccountIdentifier(this), getChara());
    
            saver.go(new DbResultListener() {
                @Override
                public void onError(String error) {
                    DialogHelper.show(BaseActivity.this, error);
                }
            });
        }
    }
    

    Also verify that the Chara instance you are saving is correctly initialized and contains the expected data.

    Additinally for more control on the background/foreground transitions, you might want to consider using a library like ProcessLifecycleOwner in Android Architecture Components.
    For example –

    // this goes in Application class or in your activity:
    AppLifecycleObserver appLifecycleObserver = new AppLifecycleObserver();
    ProcessLifecycleOwner.get().getLifecycle().addObserver(appLifecycleObserver);
    
    //  this is the AppLifecycleObserver class
    public class AppLifecycleObserver implements LifecycleObserver {
    
        @OnLifecycleEvent(Lifecycle.Event.ON_START)
        public void onEnterForeground() {
            // App comes to the foreground
            // Save data or perform actions when the app is in the foreground
        }
    
        @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
        public void onEnterBackground() {
            // this app goes in the background
            // save data here when the app is in the background
        }
    }
    

    Let me know if that works.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search