0

EDIT: THE MAIN ISSUE, DEALING WITH SAVING STATE ON BACK BUTTON PRESSED. And then re-load the bundle onCreate. Not sure, how this can be done correctly. Nothing, I tried so far has worked correctly.

I am trying to save and recover the state of a simple game I made in Android. I am utilizing, the following events: onSaveInstanceState(), onBackPressed(), onCreate() and onPostCreate(). I am making my objects Serializable and Parcelable.

However, it does not successfully remember or recover in the state, before, pushing the back button or when just switching apps. Not, sure, of what I am doing wrong here. It's a behavioral error, i.e., no real error occurs.

Dice.java

public class Dice implements Parcelable {
    private int value;
    private int currentImage;
    private boolean marked = false; 
    private boolean enabled = true;
    private final Random random = new Random();

    // Mapping of drawable resources:
    private final int[] defaultDiceImages = {0,
            R.drawable.white1, R.drawable.white2,
            R.drawable.white3, R.drawable.white4,
            R.drawable.white5, R.drawable.white6
    };

    private final int[] selectedDiceImages = { 0,
            R.drawable.grey1, R.drawable.grey2, R.drawable.grey3,
            R.drawable.grey4, R.drawable.grey5, R.drawable.grey6
    };

    private final int[] redDiceImages = { 0,
            R.drawable.red1, R.drawable.red2, R.drawable.red3,
            R.drawable.red4, R.drawable.red5, R.drawable.red6
    };

    // Constructor
    Dice() { }

    Dice(int value){
        this.value = value;
        this.currentImage = defaultDiceImages[this.value];
    }

    protected Dice(Parcel in) {
        value = in.readInt();
     /*   currentImage = in.readInt();
        marked = in.readByte() != 0;
        enabled = in.readByte() != 0;
        defaultDiceImages = in.createIntArray();
        selectedDiceImages = in.createIntArray();
        redDiceImages = in.createIntArray(); */
    }

    public static final Creator<Dice> CREATOR = new Creator<Dice>() {
        @Override
        public Dice createFromParcel(Parcel in) { return new Dice(in); }

        @Override
        public Dice[] newArray(int size) { return new Dice[size]; }
    };

    public boolean IsMarked() { return marked; }

    public int GetValue() { return value; }

    public void Toss() {
        if(enabled) {
            this.value = random.nextInt(6) + 1;
            this.currentImage = defaultDiceImages[this.value];
        }
    }

    public int GetCurrentImage(){ return currentImage; }


    public void ToggleMarked() {
        marked = !marked;
        currentImage = (this.marked) ? selectedDiceImages[this.value] : defaultDiceImages[this.value];
    }

    public void ToggleEnabled() { enabled = !enabled; }

    @NonNull
    @Override
    public String toString() {
        return "Dice{" +
                "   value=" + this.value +
                ", currentImage=" + this.currentImage +
                ", marked=" + this.marked +
                ", enabled=" + this.enabled +
                ", random=" + this.random +
                '}';
    }

    @Override
    public int describeContents() { return 0; }

    @Override
    public void writeToParcel(Parcel parcel, int i)
    {
        parcel.writeInt(this.value);
    }
} 

Score.java

public class Score implements Serializable {

    private int score = 0;
    private String choice;
    private static final int SCORE_LOW = 3;

    public Score() {}

    public Score(int score, String choice) {
        this.score = score;
        this.choice = choice;
    }

    public int getScore(){
        return this.score;
    }
    public void setScore(int score){
        this.score = score;
    }

    public String getChoice() {
        return choice;
    }

    public void setChoice(String choice) {
        choice = choice;
    }
}

GameRound.java

import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import android.view.Gravity;
import android.widget.Toast;

import java.io.Serializable;
import java.util.ArrayList;

public class GameRound implements Parcelable, Serializable {

    // Constants
    private final int NUMBER_OF_DICES = 6;
    private final int MAX_ALLOWED_THROWS = 3;

    private ArrayList<Dice> dices;

    public int totalDices;
    private int throwsLeft;
    private Score roundScore;
    
    public GameRound() {
        this.dices = GenerateNewDices();
        this.roundScore = new Score();
        this.throwsLeft = MAX_ALLOWED_THROWS;
    }

    protected GameRound(Parcel in) {
        this.totalDices = in.readInt();
        this.throwsLeft = in.readInt();
        this.dices = (ArrayList<Dice>)in.readSerializable();
        this.roundScore = (Score)in.readSerializable();
    }

    public ArrayList<Dice> GetDices() { return dices; }

    public ArrayList<Dice> GenerateNewDices() {
        ArrayList<Dice> tmp = new ArrayList<>(NUMBER_OF_DICES);
        if(totalDices == 0)
            totalDices = NUMBER_OF_DICES;

        for(int i = 0; i<totalDices; i++){
            tmp.add(new Dice(i));
        }
        return tmp;
    }

    public Score GetScore() { return roundScore; }

    public int GetThrowsLeftCount() { return throwsLeft; }

    public void SetRoundScore(Score score) {
        this.roundScore = score;
    }

    public void TossDices(Context context){
        if(ValidateAttempt(context)){
            int selectedCount = (int)this.dices.stream().filter(Dice::IsMarked).count();
            if(selectedCount > 0) {
                for (Dice d : this.dices) {
                    if(d.IsMarked()) {
                        d.Toss();
                        d.ToggleMarked(); // Reset
                    }
                }
            } else {
                for (Dice d : this.dices) {
                    d.Toss();
                }
            }
        }
    }

    private boolean ValidateAttempt(Context context) {
        if(throwsLeft > 0){
            --throwsLeft;
            return true;
        } else {
            Toast toast = Toast.makeText(context, "", Toast.LENGTH_SHORT);
            toast.setGravity(Gravity.TOP, 0, 200);
            toast.show();
            return false;
        }
    }

    public boolean CanPlay(){
        return throwsLeft != 0;
    }

    public void Reset() {
        for(Dice d: this.dices){
            d.Toss();
        }
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeInt(totalDices);
        dest.writeInt(throwsLeft);
        dest.writeSerializable(dices);
        dest.writeSerializable(roundScore);
    }

    @Override
    public int describeContents() { return 0; }

    public static final Creator<GameRound> CREATOR = new Creator<GameRound>() {
        @Override
        public GameRound createFromParcel(Parcel in) { return new GameRound(in); }

        @Override
        public GameRound[] newArray(int size) { return new GameRound[size]; }
    };
}

ThirtyThrowsGame.java:

import android.os.Parcel;
import android.os.Parcelable;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;

import androidx.core.util.Pair;

public class ThirtyThrowsGame implements Parcelable, Serializable {
    public enum ScoreChoice {
        LOW(3),
        FOUR(4),
        FIVE(5),
        SIX(6),
        SEVEN(7),
        EIGHT(8),
        NINE(9),
        TEN(10),
        ELEVEN(11),
        TWELVE(12),
        ;

        private final int value;
        ScoreChoice(int n) {
            value = n;
        }
        public int getValue() {
            return value;
        }
    }

    public final int MAX_ROUNDS; 
    private int currentRound;
    private GameRound round;
    private ArrayList<Score> scores = new ArrayList<>();
    private ArrayList<ScoreChoice> availableScoreChoices = new ArrayList<>();


    public ThirtyThrowsGame(){
        this.currentRound = 1;
        MAX_ROUNDS = 10;
        this.round = new GameRound();
        this.availableScoreChoices.addAll(Arrays.asList(ScoreChoice.values()));
        Collections.reverse(availableScoreChoices);
    }

    protected ThirtyThrowsGame(Parcel in) {
        this.scores = (ArrayList<Score>)in.readSerializable();
        MAX_ROUNDS = in.readInt();
        this.currentRound = in.readInt();
        this.round = in.readParcelable(GameRound.class.getClassLoader());
        this.availableScoreChoices.addAll(Arrays.asList(ScoreChoice.values()));
        Collections.reverse(availableScoreChoices);
    }

    private static int[] DiceValuesToArray(ArrayList<Dice> dices) {
        int[] a = new int[dices.size()];
        for (int i = 0; i < dices.size(); i++) {
            a[i] = dices.get(i).GetValue();
        }
        return a;
    }

    public int calculateScoreLow(ArrayList<Dice> dices, int value) {
        int sum = 0;
        int[] a = DiceValuesToArray(dices);
        for (int j : a) {
            if (j <= value) {
                sum += j;
            }
        }
        return sum;
    }

    public int calculateScore(ArrayList<Dice> dices, int value) {
        ArrayList<Integer> matches = new ArrayList<>();
        int[] a = DiceValuesToArray(dices);
        for(int i = 0; i<a.length; ++i)
            for(int j = i + 1; j<a.length; ++j)
                if(a[i] + a[j] == value){ // <- Pairs that match sum
                    matches.add(a[i]);
                    matches.add(a[j]);
                    break;
                } else if(a[i] == value){ // <- Single candidates that match sum
                    matches.add(a[i]); break;
                }
        return matches.stream().mapToInt(Integer::intValue).sum(); // <- Return the sum of ints
    }

    public void Restart(){
        Clear();
        this.round = new GameRound();
    }

    public ArrayList<Score> GetRegistredScores() { return scores; }
    public void SaveScore() { this.scores.add(round.GetScore()); }
    public int TotalScore() {
       return this.scores.stream().mapToInt(Score::getScore).sum();
    }
    public boolean NextRound() {
        if(currentRound < MAX_ROUNDS) {
            ++currentRound;
            SaveScore();
            this.round = null;
            this.round = new GameRound();
            return false;
        }

        SaveScore();
        return true;
    }

    public int GetCurrentRound() {
        return currentRound;
    }
    
    public GameRound GetCurrentGameRound() { return round; }

    public ArrayList<ScoreChoice> GetAvailableScoreChoices(){
        return new ArrayList<>(availableScoreChoices);
    }

    public void Clear(){
        this.currentRound = 1;
        //rounds.clear();
        this.scores.clear();
        this.round = null;
    }

   
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeSerializable((scores));
        dest.writeInt(MAX_ROUNDS);
        dest.writeInt(currentRound);
        dest.writeSerializable(round);
    }

    @Override
    public int describeContents() { return 0; }

    public static final Creator<ThirtyThrowsGame> CREATOR = new Creator<ThirtyThrowsGame>() {
        @Override
        public ThirtyThrowsGame createFromParcel(Parcel in) { return new ThirtyThrowsGame(in); }

        @Override
        public ThirtyThrowsGame[] newArray(int size) { return new ThirtyThrowsGame[size]; }
  

}; }

MainActivity.java:

 // Parcelable key
    private final String STATE_GAME = "STATE_GAME";

    // Classes
    private ThirtyThrowsGame game;

    // View components
    private Button rollBtn;
    private Button collectScoreBtn;
    private TextView roundText;
    private TextView currentScoreText;
    private Spinner scoreSelectionSpinner;
    private ArrayAdapter<ThirtyThrowsGame.ScoreChoice> adapter;
    private ArrayList<ImageView> diceViews;

    // Data
    int score = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Find elements on the UI by id.
        rollBtn = findViewById(R.id.btnRoll);
        collectScoreBtn = findViewById(R.id.btnCollectScore);
        scoreSelectionSpinner = findViewById(R.id.spinner);
        roundText = findViewById(R.id.RoundText);
        currentScoreText = findViewById(R.id.CurrentScoreText);

        if (savedInstanceState != null) {
            this.game = (ThirtyThrowsGame) savedInstanceState.getSerializable(STATE_GAME);
        } else {
            this.game = new ThirtyThrowsGame();
        }

        rollBtn.setOnClickListener(e -> {
            if (game.GetCurrentGameRound().CanPlay()) {
                RefreshScene(this);
                SetScore();
            } else {
                Toast toast = Toast.makeText(
                        this,
                        "Please collect score to run next round.",
                        Toast.LENGTH_SHORT
                );
                toast.show();
            }
        });

        collectScoreBtn.setOnClickListener(e -> {
            scoreSelectionSpinner.setSelection(0);
            boolean _continue = game.NextRound();
            score = 0;
            SetDefaultDiceView();
            RefreshScene();

            if (_continue) {
                NextActivity();
            }
        });

        SetupDropDown();
        GetDiceViews();
        SetupDiceClickEventListeners();

        currentScoreText.setText(getString(R.string.score, 0));
        roundText.setText(getString(R.string.round, game.GetCurrentRound()));
        RefreshRollButtonText();
    }

    @Override
    protected void onPostCreate(@Nullable Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        if (savedInstanceState != null) {
            if (game != null) {
                score = game.GetCurrentGameRound().GetScore().getScore();
                if (score == 0) {
                    SetDefaultDiceView();
                } else {
                    UpdateImageViews();
                }
                RefreshScene();
            }
        }
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        outState.putSerializable(STATE_GAME, game);
        super.onSaveInstanceState(outState);
    }

    @Override
    public void onBackPressed() {
        moveTaskToBack(true);
    }

 private void SetDefaultDiceView() {
        diceViews.get(0).setImageResource(R.drawable.white1);
        diceViews.get(1).setImageResource(R.drawable.white2);
        diceViews.get(2).setImageResource(R.drawable.white3);
        diceViews.get(3).setImageResource(R.drawable.white4);
        diceViews.get(4).setImageResource(R.drawable.white5);
        diceViews.get(5).setImageResource(R.drawable.white6);
    }

private void SetupDiceClickEventListeners() {
        int index = 0;
        for (ImageView v : diceViews) {
            int finalIndex = index;
            v.setOnClickListener(e -> {
              this.game.GetCurrentGameRound().GetDices().get(finalIndex).ToggleMarked();
                diceViews.get(finalIndex).setImageResource(game.GetCurrentGameRound()
                        .GetDices().get(finalIndex).GetCurrentImage());
            });
            ++index;
        }
    }

 public void RefreshScene() {
        roundText.setText(getString(R.string.round, game.GetCurrentRound()));
        currentScoreText.setText(getString(R.string.score, game.GetCurrentGameRound().GetScore().getScore()));
        RefreshRollButtonText();
    }

    public void RefreshScene(Context context) {
        game.GetCurrentGameRound().TossDices(context);
        roundText.setText(getString(R.string.round, game.GetCurrentRound()));
        UpdateImageViews();
        RefreshRollButtonText();
    }

This is pretty much almost all of the code here. If someone, can spot the issues, please let me know. I been struggling with this for weeks now, and it is getting deeply annoying. I need to ensure, it works when you leave the back in the background, so that it recovers the game state and continues to reflect this on the UI.

EDIT: Yes, I ran this in debug mode stepping throw the code.

EDIT 6/27/2022: I have developer mode enabled with "Don't keep activities" enabled.

John Smith
  • 465
  • 4
  • 15
  • 38
  • 2
    According to [this](https://developer.android.com/topic/libraries/architecture/saving-states#ui-dismissal-user) saved state is destroyed when back is pressed. Maybe saving it in SharedPreferences is what you need here instead, although it should work switching apps... – Tyler V Jun 26 '22 at 13:15
  • @TylerV, yes. But how can you save the bundle? `SharedPreferences` cannot handle `Parcelable`, right? I tested this earlier today. – John Smith Jun 26 '22 at 13:28
  • 1
    No, you'd have to serialize it some other way. Saving it as a JSON string is a common approach. Have a look [here](https://stackoverflow.com/questions/28439003/use-parcelable-to-store-item-as-sharedpreferences) – Tyler V Jun 26 '22 at 13:30
  • @TylerV, what you are suggesting, is basically, to jsonify the object along with everything else, and then use the `SharedPreference` class? – John Smith Jun 26 '22 at 13:32
  • 1
    Yep, that may be what you need to do to get it to persist past a back press – Tyler V Jun 26 '22 at 13:35
  • @TylerV, *I will try this now* DAFAQ! ;-) – John Smith Jun 26 '22 at 13:36

2 Answers2

1

Since saved instance state is cleared when the back button is pressed, you will need to store your game data in something more persistent. One common choice is to serialize it to Json and save it in SharedPreferences. You can do this with the Moshi library pretty easily.

ThirtyThrowsGame

private static final Moshi moshi = new Moshi
        .Builder()
        .add(new ScoreArrayListMoshiAdapter())
        .add(new ScoreChoiceArrayListMoshiAdapter())
        .build();

private static final JsonAdapter<ThirtyThrowsGame> jsonAdapter = moshi.adapter(ThirtyThrowsGame.class);

static ThirtyThrowsGame fromJson(String json) {
    try {
        return jsonAdapter.fromJson(json);
    }
    catch(IOException e) {
        return null;
    }
}

String toJson() {
    return jsonAdapter.toJson(this);
}

// Moshi doesn't play nice with ArrayList, so you need to add
// these to convert your ArrayList<X> back and forth to List<X>
static class ScoreArrayListMoshiAdapter {
    @ToJson
    List<Score> arrayListToJson(ArrayList<Score> list) {
        return list;
    }

    @FromJson
    ArrayList<Score> arrayListFromJson(List<Score> list) {
        return new ArrayList<>(list);
    }
}

static class ScoreChoiceArrayListMoshiAdapter {
    @ToJson
    List<ScoreChoice> arrayListToJson(ArrayList<ScoreChoice> list) {
        return list;
    }

    @FromJson
    ArrayList<ScoreChoice> arrayListFromJson(List<ScoreChoice> list) {
        return new ArrayList<>(list);
    }
}

No changes are needed to the other classes other than removing the Parcelable and Serializable interfaces. Then in your Activity you can use

private ThirtyThrowsGame game;
private SharedPreferences prefs;
private final String GAME = "SAVED_GAME";

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    prefs = PreferenceManager.getDefaultSharedPreferences(this);
    
    if( prefs.contains(GAME) ) {
        String json = prefs.getString(GAME, "{}");
        game = ThirtyThrowsGame.fromJson(json);
    }
    else {
        game = new ThirtyThrowsGame();
    }
}

// When the game is "done" and you actually want to clear it, delete it
// from SharedPrefs yourself and exit without saving the state.
private boolean save_on_exit = true;
private void finishGame() {
    prefs.edit().remove(GAME).apply();
    save_on_exit = false;
    finish();
}

// save the game at some point in the lifecycle, either onPause
// or onStop - unless the game is finishing
@Override
protected void onPause() {
    super.onPause();
    if( save_on_exit ) {
        prefs.edit().putString(GAME, game.toJson()).apply();
    }
}

To use Moshi, just add this to the Gradle file

implementation 'com.squareup.moshi:moshi:1.13.0'
Tyler V
  • 9,694
  • 3
  • 26
  • 52
  • Hi Tyler, have you tried this on your side? Use the "Don't keep activities" enabled under developer settings in Android emulator. – John Smith Jun 27 '22 at 15:20
  • Yes, I ran this on my side. That shouldn't affect SharedPreferences persistence. – Tyler V Jun 27 '22 at 15:23
  • Please test it, with that enabled and let me know if it works. – John Smith Jun 27 '22 at 15:49
  • Yes, I tested it and it works with that setting on. The game is persisted - however you do have to make some edits to handle the "finishGame" case properly (e.g. do you want it to exit without saving when the game is "finished", or make a new game and save that state to SharedPreferences?) – Tyler V Jun 27 '22 at 16:10
  • How's your MainActivity.java ? Do you use **onCreate** and **onSaveInstanceState(...)** methods? When I run debug mode with that dev option **On** my **onCreate** is never invoked when trying to switch back to the application. – John Smith Jun 27 '22 at 16:35
  • 1
    I posted the entire contents of MainActivity above - it uses onCreate but not onSaveInstanceState. It is impossible to launch a fresh activity without calling onCreate... I suggest you try adding some Log or print statements - when I ran it, it called `onCreate` every time... You could also put a log in `onResume` to see if it is just resuming an existing activity instead of re-creating it. – Tyler V Jun 27 '22 at 16:37
  • With, **"Don't keep activities"** Enabled, it will always destroy. Hence, recreation. – John Smith Jun 27 '22 at 16:41
  • Yes, which means it **must** call onCreate every time. Which is the behavior I was seeing when using Log/Print statements. – Tyler V Jun 27 '22 at 16:42
  • How were you able to stay in **debug mode**? – John Smith Jun 27 '22 at 16:42
  • I have no idea what you are asking, and at this point I cannot debug your app for you. Put a print statement in and run the app without trying to attach a line-by-line debugger. – Tyler V Jun 27 '22 at 16:43
  • With, **"Don't keep activities"** enabled, it destroys the app while running regardless of debug mode or normal mode, due to it being enabled. If I am not incorrect. Therefore, while in Debug mode, with break points, the app just stops after trying to switch because it is disposed i.e. automatically destroyed thus exiting as well. I am unable to stay active in debug and step through the code. **Were you able to do this?**, **IF, YES, how did you manage to make it work?** – John Smith Jun 27 '22 at 16:47
  • I was not using "debug mode" or "breakpoints" - I just ran the app with appropriate log statements. This sounds like a totally unrelated issue with using the debugger itself and the "dont keep activities" option - it has nothing to do with the original question. – Tyler V Jun 27 '22 at 16:49
0

To save activity state override onSaveInstanceState method and to restore the state override onRestoreInstanceState method.

class GameObj extends Serializable
{
  // game members

  public Obj() {}

  // getters and 
}
@Override
public void onSaveInstanceState(Bundle savedInstanceState) {
  super.onSaveInstanceState(savedInstanceState);
  savedInstanceState.putSerializable("my_obj", obj);
}

@Override
public void onRestoreInstanceState(Bundle savedInstanceState) {
  super.onRestoreInstanceState(savedInstanceState);
  GameObj obj = savedInstanceState.getSerializable("my_obj", obj);
}
Sidharth Mudgil
  • 1,293
  • 8
  • 25