FacebookTwitterGoogle+Share

Heaps/Hashlink saving data for Android

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 vstrings, 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.

Comments

You must be logged in to post a comment.