diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md
index dfc17853..b0bc4479 100644
--- a/mobile/ANDROID.md
+++ b/mobile/ANDROID.md
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
+### Step bridge (Pokéwalker mod)
+
+`love.system.syncHealthSteps()` → `GameActivity.syncHealthSteps` (same
+JNI route as the picker: `common/android.cpp` →
+`modules/system/System.cpp` → `wrap_System.cpp`). The Java side does a
+one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
+since boot, counted by the OS whether or not any app runs), anchors the
+reading in `SharedPreferences` so a walk is never credited twice
+(a reading below the anchor means the phone rebooted → re-anchor without
+crediting), and stages the delta as `steps_pending.json` in the save
+identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
+the base game calls it; the consumer is the
+[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
+installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
+off).
+
+Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
+permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
+of the build script's permission trim). The first
+`syncHealthSteps()` call shows the system prompt; on grant the sensor
+read runs immediately (`onRequestPermissionsResult`,
+`STEP_PERMISSION_REQUEST_CODE`).
+
### SDK / NDK
love-android 11.5a expects:
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 1f39040d..5cf7b38a 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
+
+
diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp
index 7f0cfdca..5ddc77f7 100644
--- a/mobile/android/love/src/jni/love/src/common/android.cpp
+++ b/mobile/android/love/src/jni/love/src/common/android.cpp
@@ -219,6 +219,18 @@ bool showCreateDocument(const char *suggestedName)
return result;
}
+bool syncHealthSteps()
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+
+ jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
+ jboolean result = env->CallStaticBooleanMethod(activity, method);
+
+ env->DeleteLocalRef(activity);
+ return result;
+}
+
/*
* Helper functions for the filesystem module
*/
diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h
index 10cd0802..63f93029 100644
--- a/mobile/android/love/src/jni/love/src/common/android.h
+++ b/mobile/android/love/src/jni/love/src/common/android.h
@@ -74,6 +74,13 @@ bool showFilePicker(const char *destFilename = nullptr);
**/
bool showCreateDocument(const char *suggestedName = nullptr);
+/**
+ * Pokéwalker step bridge: asks GameActivity to read the hardware step
+ * counter and stage steps_pending.json in the save identity dir (see
+ * GameActivity.syncHealthSteps). Returns whether a sync could start.
+ */
+bool syncHealthSteps();
+
/*
* Helper functions for the filesystem module
*/
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
index 0a844e58..0ca2a484 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
@@ -212,6 +212,15 @@ bool System::createFile(const char *suggestedName) const
#endif
}
+bool System::syncHealthSteps() const
+{
+#ifdef LOVE_ANDROID
+ return love::android::syncHealthSteps();
+#else
+ return false;
+#endif
+}
+
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h
index c3a174fa..8ef6aee4 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.h
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.h
@@ -127,6 +127,12 @@ public:
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
+ /**
+ * Pokéwalker: stage pending real-world steps (steps_pending.json in the
+ * save dir) from the platform step source. Android-only; false elsewhere.
+ */
+ virtual bool syncHealthSteps() const;
+
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
index 07d572a1..9127dbb2 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
@@ -109,6 +109,12 @@ int w_createFile(lua_State *L)
return 1;
}
+int w_syncHealthSteps(lua_State *L)
+{
+ luax_pushboolean(L, instance()->syncHealthSteps());
+ return 1;
+}
+
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +132,7 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
+ { "syncHealthSteps", w_syncHealthSteps },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
index f8374566..d43e1e93 100644
--- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
+++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
@@ -41,13 +41,20 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
import android.os.Vibrator;
import android.util.Log;
import android.util.DisplayMetrics;
@@ -67,6 +74,7 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
+ public static final int STEP_PERMISSION_REQUEST_CODE = 6;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -83,6 +91,17 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
+ // Step bridge (love.system.syncHealthSteps): pending-steps delivery
+ // consumed by the Pokéwalker mod, same contract as the iOS
+ // GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
+ // (cumulative since boot, counted by the OS whether or not any app is
+ // running), anchored in SharedPreferences so a walk is never credited
+ // twice.
+ private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
+ private static final String STEP_PREFS = "pokewalker_steps";
+ private static final String STEP_PREF_ANCHOR = "anchor";
+ private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
+ private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
@@ -516,6 +535,160 @@ public class GameActivity extends SDLActivity {
}
}
+ /**
+ * Step sync, called from Lua as love.system.syncHealthSteps()
+ * (see modules/system/wrap_System.cpp). Asynchronous like the picker:
+ * returns whether a sync could be started; the result lands later as
+ * steps_pending.json in the save identity dir, where the Pokéwalker
+ * mod's poll consumes it.
+ *
+ * Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
+ * runtime permission; the first call shows the system prompt and a later
+ * sync (the mod retries on save load / option change) delivers.
+ */
+ @Keep
+ public static boolean syncHealthSteps() {
+ final GameActivity self = (GameActivity) mSingleton;
+ if (self == null) return false;
+ if (android.os.Build.VERSION.SDK_INT >= 29
+ && ActivityCompat.checkSelfPermission(self,
+ Manifest.permission.ACTIVITY_RECOGNITION)
+ != PackageManager.PERMISSION_GRANTED) {
+ self.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ ActivityCompat.requestPermissions(self,
+ new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
+ STEP_PERMISSION_REQUEST_CODE);
+ }
+ });
+ return true;
+ }
+ self.startStepSensorRead();
+ return true;
+ }
+
+ /**
+ * One-shot read of the cumulative hardware step counter. The sensor
+ * usually reports its cached value moments after registration; some
+ * devices hold the event until the next physical step, so the listener
+ * is given 20 seconds before being torn down (the next sync retries).
+ */
+ private void startStepSensorRead() {
+ final SensorManager manager =
+ (SensorManager) getSystemService(Context.SENSOR_SERVICE);
+ if (manager == null) return;
+ Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
+ if (counter == null) {
+ Log.d("GameActivity", "no step counter sensor on this device");
+ return;
+ }
+ final SensorEventListener listener = new SensorEventListener() {
+ private boolean delivered = false;
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ if (delivered || event.values.length == 0) return;
+ delivered = true;
+ manager.unregisterListener(this);
+ deliverSteps((long) event.values[0]);
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ }
+ };
+ if (!manager.registerListener(listener, counter,
+ SensorManager.SENSOR_DELAY_NORMAL)) {
+ Log.d("GameActivity", "step counter listener registration failed");
+ return;
+ }
+ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ // No-op if the listener already delivered and unregistered.
+ manager.unregisterListener(listener);
+ }
+ }, 20000);
+ }
+
+ /**
+ * Convert a cumulative counter reading into pending steps. The counter
+ * resets to zero on reboot: a reading below the stored anchor re-anchors
+ * without crediting (steps walked between the reboot and this sync are
+ * lost, which errs on the honest side).
+ */
+ private void deliverSteps(long counterNow) {
+ SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
+ long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
+ long now = System.currentTimeMillis();
+ if (anchor < 0 || counterNow < anchor) {
+ prefs.edit()
+ .putLong(STEP_PREF_ANCHOR, counterNow)
+ .putLong(STEP_PREF_ANCHOR_WALLTIME, now)
+ .apply();
+ Log.d("GameActivity", "step anchor set at " + counterNow);
+ return;
+ }
+ long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
+ long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
+ if (steps <= 0) return;
+ prefs.edit()
+ .putLong(STEP_PREF_ANCHOR, counterNow)
+ .putLong(STEP_PREF_ANCHOR_WALLTIME, now)
+ .apply();
+
+ File dir = saveIdentityDir();
+ if (!dir.isDirectory() && !dir.mkdirs()) {
+ Log.d("GameActivity", "cannot create save dir for steps: " + dir);
+ return;
+ }
+ File pending = new File(dir, PENDING_STEPS_FILENAME);
+ long total = steps;
+ // Merge with an unconsumed earlier delivery so steps are never lost
+ // (same contract as the iOS bridge).
+ if (pending.isFile()) {
+ try {
+ byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
+ FileInputStream in = new FileInputStream(pending);
+ int read = in.read(raw);
+ in.close();
+ if (read > 0) {
+ org.json.JSONObject old =
+ new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
+ total += Math.max(0, old.optLong("steps", 0));
+ }
+ } catch (Exception e) {
+ Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
+ }
+ }
+ try {
+ org.json.JSONObject payload = new org.json.JSONObject();
+ payload.put("steps", total);
+ payload.put("from", isoTime(fromWalltime));
+ payload.put("to", isoTime(now));
+ File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
+ FileOutputStream out = new FileOutputStream(tmp);
+ out.write(payload.toString().getBytes("UTF-8"));
+ out.close();
+ if (!tmp.renameTo(pending)) {
+ tmp.delete();
+ Log.d("GameActivity", "could not publish pending steps");
+ return;
+ }
+ Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
+ } catch (Exception e) {
+ Log.d("GameActivity", "could not write pending steps: " + e);
+ }
+ }
+
+ private static String isoTime(long millis) {
+ java.text.SimpleDateFormat format =
+ new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
+ format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
+ return format.format(new java.util.Date(millis));
+ }
+
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -739,6 +912,17 @@ public class GameActivity extends SDLActivity {
}
break;
}
+ case STEP_PERMISSION_REQUEST_CODE: {
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ Log.d("GameActivity", "Step permission granted");
+ // Deliver right away so the sync the player just
+ // opted into doesn't wait for the next launch.
+ startStepSensorRead();
+ } else {
+ Log.d("GameActivity", "Did not get step permission.");
+ }
+ break;
+ }
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
diff --git a/scripts/build_android.sh b/scripts/build_android.sh
index cc4c8232..417ff535 100755
--- a/scripts/build_android.sh
+++ b/scripts/build_android.sh
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
+ # Deliberately NO fused mods: a mod inside game.love sits in the read-only
+ # APK, so the mod manager's Delete can't remove it and it reappears every
+ # launch. Pokewalker ships as an importable .zip instead, which gives it
+ # a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
- say "building APK ($task)"
+ local build_dir="$ANDROID_DIR"
+ # ndk-build is GNU make underneath and cannot cope with spaces anywhere in
+ # the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
+ # When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
+ # shadow the android tree to a space-free location and build there; the
+ # shadow persists across runs so gradle/ndk builds stay incremental.
+ case "$ANDROID_DIR" in
+ *" "*)
+ build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
+ say "path contains spaces (ndk-build cannot handle them);"
+ say "shadow-building in: $build_dir"
+ mkdir -p "$build_dir"
+ rsync -a --delete \
+ --exclude=".gradle" --exclude="app/build" --exclude="love/build" \
+ --exclude="local.properties" \
+ "$ANDROID_DIR/" "$build_dir/"
+ if [ -f "$ANDROID_DIR/local.properties" ]; then
+ cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
+ fi
+ ;;
+ esac
+
+ say "building APK ($task)"
if ! (
- cd "$ANDROID_DIR"
+ cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
- local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
+ local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;