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 {} \;