When I was making Hexlock, I was disappointed but not overly surprised to find that hxd.Save didn’t work out-of-the-box on Android. It’s quite possible that it would have after some simple changes, but at that point I was too tired to dig through source files to try to discover why and what those changes may be.
So I just implemented my own method of saving using Android shared preferences.
The first step was to load and store data differently when compiling for Android, which wasn’t too bad because I already had my own wrapper to handle that. A lot of this will contain code specific to Hexlock, which you can ignore or not at your discretion. Basically I keep a map of levels (JSON strings) to score data, load it once when the game starts, and save the entire map out on a new high score.
public static function loadSaves():Void {
var defValue = new Map<String, Highscore>(); // The default value
#if android
var bytes = Main.load(getInstance().name); // These are hl.Bytes
var data = @:privateAccess String.fromUTF8(bytes); // Convert it to a String
// This is 100% overkill but a wise man once said:
// It is better to just throw in random possible values
// than to wait for Android Studio to finish compiling and check
// what they actually are.
if (data.length == 0 || data == "null" || data == null) {
// Welp, at least we have the default value
getInstance().data = defValue;
return;
}
// This code is basically taken from hxd.Save
var obj : Dynamic = haxe.Unserializer.run(data);
// set all fields that were not set to default value (auto upgrade)
if( defValue != null && Reflect.isObject(obj) && Reflect.isObject(defValue) ) {
for( f in Reflect.fields(defValue) ) {
if( Reflect.hasField(obj, f) ) continue;
Reflect.setField(obj, f, Reflect.field(defValue,f));
}
}
getInstance().data = obj; // That's our data now
#else
// If it's not for Android, just use hxd.Save
getInstance().data = Save.load(defValue, getInstance().name);
#end
}
public static function save(level:Level, highschore:Highscore) {
getInstance().data.set(Json.stringify(level), highscore); // Add the score
#if android
var data = haxe.Serializer.run(getInstance().data);
Main.save(getInstance().name, data);
#else
Save.save(getInstance().data, getInstance().name);
#end
}
For both save()
and loadSaves()
hxd.Save is used, except when compiling to Android, because I added -D android
to my compile-to-c.hxml. There may already be a hlNative one #shrug. I didn’t see one, and adding -D android
was easy enough.
You can see from above that the code requires the function Main.save(String, String)
and Main.load(String)
; getInstance().name
is just a constant string identifier like ‘my_cool_highscore_file’.
Since those functions are only referenced when compiling for Android, they only need to be defined when compiling for Android, which is convenient because hl.Bytes
isn’t available on non-hashlink targets.
And in Main.hx:
#if android
@:hlNative("Java_io_heaps_android_HeapsActivity")
public static function save(name:String, data:String) {}
#end
#if android
@:hlNative("Java_io_heaps_android_HeapsActivity")
public static function load(name:String):hl.Bytes { var i = 4; return hl.Bytes.fromValue("null", i); }
#end
Who knows if I needed a body for load(String)
– not me – but it worked, so I didn’t look into it further.
When compiling to C code for Hashlink, calls to the function Main.save()
get replaced with Java_io_heaps_android_HeapsActivity_save()
, which I defined in my jni.c1 file, and of course an equivalent transformation happens to Main.load()
.
JNIEXPORT vbyte* JNICALL Java_io_heaps_android_HeapsActivity_load(vstring *name) {
(*jvm)->AttachCurrentThread(jvm, &thisEnv, 0); //this is important to avoid threading errors
jclass cls = (*thisEnv)->FindClass(thisEnv, "io/heaps/android/HeapsActivity");
jmethodID method = (*thisEnv)->GetStaticMethodID(thisEnv, cls, "loadData", "(Ljava/lang/String;)Ljava/lang/String;");
char *cname = hl_to_utf8(name->bytes);
__android_log_print(ANDROID_LOG_DEBUG, "JNI.c", "loading... %s", cname);
// Is this necessary? Who knows! Better safe than seg fault though.
char *buf = strdup(cname);
jstring jstrBuf = (*thisEnv)->NewStringUTF(thisEnv, buf);
jstring result = (*thisEnv)->NewStringUTF(thisEnv, "null");
if (method > 0)
result = (jstring) (*thisEnv)->CallStaticObjectMethod(thisEnv, cls, method,jstrBuf);
char *cresult = (*thisEnv)->GetStringUTFChars(thisEnv, result, 0);
__android_log_print(ANDROID_LOG_DEBUG, "JNI.c", "returned... %s",cresult);
free(buf); // Free the string
return cresult;
}
JNIEXPORT jstring JNICALL Java_io_heaps_android_HeapsActivity_save(vstring *name, vstring *data) {
(*jvm)->AttachCurrentThread(jvm, &thisEnv, 0); //this is important to avoid threading errors
jclass cls = (*thisEnv)->FindClass(thisEnv, "io/heaps/android/HeapsActivity");
jmethodID method = (*thisEnv)->GetStaticMethodID(thisEnv, cls, "saveData", "(Ljava/lang/String;Ljava/lang/String;)V");
char *cname = hl_to_utf8(name->bytes);
char *cdata = hl_to_utf8(data->bytes);
char *bname = strdup(cname);
char *bdata = strdup(cdata);
__android_log_print(ANDROID_LOG_DEBUG, "JNI.c", "saving %s: %s", bname, bdata);
jstring jname = (*thisEnv)->NewStringUTF(thisEnv, bname);
jstring jdata = (*thisEnv)->NewStringUTF(thisEnv, bdata);
if (method > 0)
(*thisEnv)->CallStaticVoidMethod(thisEnv, cls, method, jname, jdata);
free(bname);
free(bdata);
}
To find out more about where thisEnv
and jvm
come from, check out triggering vibration on Android from Heaps/Hashlink.
Haxe strings come through Hashlink as vstring
s, and the JNI uses jstrings
, so you can see me converting between the two, above. Onload I just send back the char*
instead of dealing with transforming it back into a vstring
, and handle that in Haxe (which you can see even further above). Is this a memory leak? Who knows! Not me. But if it is, oh well – the load function is only called once during the apps lifetime.
As you can see, the C code assumes there will be saveData()
and loadData()
functions in io.heaps.android.HeapsActivity, and there will be, because we’re about to add them!
static public void saveData(String name, String data) {
SharedPreferences sharedPref = getContext().getSharedPreferences(name, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString(name, data);
editor.apply();
}
static public String loadData(String name) {
SharedPreferences sharedPref = getContext().getSharedPreferences(name, Context.MODE_PRIVATE);
return sharedPref.getString(name, "null");
}
Above you can see that I just use name
for both the SharedPreferences file and for the key in it. Whoops, that’s embarrassing. I could have used another string defined in the java, or just omitted specifying a name completely but it works and that’s good enough for me.
If you want to find out more about the files I’m referencing (including a Git repo hosting them), you can do so here: Hello World; Heaps on Android.