Add Android Game-Mode API listener support (#1995) - #2961
jaime-jmebot wants to merge 6 commits into
Conversation
Adds support for the Android Game-Mode API to `jme3-android`, so applications can bind their own logic to the game mode selected by the user (Android 12+). ### New public API (`com.jme3.system.android`) * `GameMode` — enum mirroring the platform constants: `UNSUPPORTED` (0), `STANDARD` (1), `PERFORMANCE` (2), `BATTERY` (3), plus `isSupported()`, `getValue()` and `fromValue(int)`. * `OnGameModeChanged` — `void onGameModeChanged(GameMode gameMode)`. This is the single-listener equivalent of the per-mode callbacks described in the issue: `PERFORMANCE` ~ `onPerformanceEnabled`, `BATTERY` ~ `onBatterySaverEnabled`, `STANDARD` ~ `onStandardEnabled`, `UNSUPPORTED` ~ `onDisabled`. * `AndroidGameMode` — reflection-based bridge to `android.app.GameManager`: reads the current mode, registers/unregisters a `GameManager.OnGameModeChangedListener` proxy and re-reads the mode when a listener is registered, so the listener is always notified once with the current mode (including `UNSUPPORTED` on Android 11 and older, or when the platform does not report a game mode). Callbacks are dispatched on the Android main thread. ### Wiring * `JmeSurfaceView#setOnGameModeChanged(OnGameModeChanged)` (the class named in the issue), registered lazily from the view context and unregistered in `destroy()`. * `AndroidHarnessFragment#setOnGameModeChanged(OnGameModeChanged)` (the non-deprecated harness): the context is captured in `onAttach`, the listener is registered immediately when the fragment is already attached, otherwise at the end of `onCreate`, and unregistered in `onDestroy`. ### Backward compatibility No `android.app.GameManager`, `Build.VERSION_CODES.S` or other API-31 symbol is referenced at compile time or in any public signature; all platform access goes through `java.lang.reflect` and every failure degrades gracefully (logged, mode reported as `UNSUPPORTED`). Devices older than Android 12 keep working unchanged. ### Example `jme3-android-examples`: new self-contained `TestGameModeActivity` (declared in `AndroidManifest.xml`) that registers the listener on a `JmeSurfaceView` and prints/logs every game mode change, with comments on the kind of logic a game may apply: ``` adb shell am start -n org.jmonkeyengine.jme3androidexamples/.TestGameModeActivity adb logcat -s TestGameModeActivity ``` ### Validation The sandbox could not provision the Gradle wrapper distribution nor a pre-populated Gradle cache (`Could not create parent directory for lock file ... gradle-9.4.1-bin.zip.lck`), so `./gradlew` could not run here; CI is the gate for the Gradle build. Validation performed instead with plain javac: * all `jme3-core` sources compiled (`src/main/java`, `src/plugins/java`, `src/tools/java`); * all `jme3-android/src/main/java` sources compiled against `lib/android.jar` (with the in-tree androidx stubs plus minimal annotation/lifecycle stubs); * `TestGameModeActivity` compiled against the freshly built `jme3-core` and `jme3-android` classes. Two real compilation problems were found and fixed during this validation: the in-tree `androidx.fragment.app.Fragment` stub has no `getContext()` (the harness now uses the `onAttach` context), and the example no longer depends on `MainActivity`/`R`. Fixes jMonkeyEngine#1995.
riccardobl
left a comment
There was a problem hiding this comment.
This PR has some problems, please check the review.
Also, please, implement GameManager.setGameState() available since API 33.
| * @see GameMode | ||
| * @see OnGameModeChanged | ||
| */ | ||
| public class AndroidGameMode { |
There was a problem hiding this comment.
Don't use reflection, please use if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ) { and access access GameManager directly.
Increase compile sdk if needed.
This won't break android 11 as long as the new API calls are guarded.
There was a problem hiding this comment.
Agreed — the reflection is unnecessary and is in fact what breaks the feature (see the listener thread below). The bridge will call GameManager directly, guarded per member by the level it was added: getGameMode() / GAME_MODE_* = API 31 (S), setGameState(...) = API 33 (TIRAMISU), GAME_MODE_CUSTOM = API 34 (UPSIDE_DOWN_CAKE).
Two build consequences to settle with it: jme3-android has no AGP compileSdk (see jme3-android/build.gradle, it compiles against the checked-in lib/android.jar), so that jar has to be bumped to API 34; and the GameManager instance still needs a null check, since the platform does not guarantee an instance on all devices (Wear devices may not publish one).
There was a problem hiding this comment.
Blocked on the compile SDK, so no commit yet — I did not want to push code that cannot build.
The reflection-free bridge is written (API 31 guard for getGameMode()/GAME_MODE_*, API 33 guard for setGameState(GameState), no listener proxy, mode read on resume, CUSTOM(4)), but jme3-android has no AGP compileSdk: it compiles against the checked-in lib/android.jar (jme3-android/build.gradle, compileOnly), and the CI build job for the engine has no Android SDK, so that jar is the only source of android.* symbols in the build.
That jar is too old for this code. Verified in the sandbox on this checkout:
-rw-r--r-- 1 root root 46784255 lib/android.jar
$ javap -classpath lib/android.jar android.app.GameManager
Error: class not found: android.app.GameManager
$ javap -classpath lib/android.jar 'android.os.Build$VERSION_CODES' | grep -E ' (S|TIRAMISU|UPSIDE_DOWN_CAKE) = '
(no output)
So javac of the bridge against this jar fails, and "increase compile sdk" here means replacing lib/android.jar with an android-34 jar — a ~47 MB binary, which my patch path (text files only) cannot write.
Question: do you want to land the jar bump (or resolve #1148 so the jar comes from the local SDK) and then I push the reflection-free version? In the meantime I can land an interim fix that keeps reflection but reads GameManager.getGameMode() on resume and adds CUSTOM(4) — say the word and I'll push that instead.
There was a problem hiding this comment.
Understood. Going with the reflection-free version, queued as an action item. Scope it locks in:
android.app.GameManagerused directly, guarded per member:getGameMode()/GAME_MODE_*atS,setGameState(GameState)atTIRAMISU,GAME_MODE_CUSTOMatUPSIDE_DOWN_CAKE.- Listener proxy,
register()/unregister()and the re-read-on-registration dropped; the mode is read on resume (JmeSurfaceViewonON_RESUME,AndroidHarnessFragment.onResume()) and pushed to the jME listener. GameMode.CUSTOM(4);fromValue()keeps mapping unknown values toUNSUPPORTED.- Null-safe
GameManagerlookup, andandroid:isGame="true"for the example app so the platform actually reports a mode.
One open point I cannot settle myself: jme3-android compiles against the checked-in lib/android.jar (compileOnly in jme3-android/build.gradle), and that jar has neither android.app.GameManager nor VERSION_CODES.S/TIRAMISU/UPSIDE_DOWN_CAKE, so the reflection-free sources do not compile against it. It is a ~47 MB binary, which I cannot write through my patch path. Two ways out:
- you land an android-34
lib/android.jar(or resolve android.jar should be loaded from the local android sdk installation and not shipped with the engine. #1148 so the jar comes from the SDK), or - I add the required API-31/33/34 signatures as a compile-only stub source set in
jme3-android, following the existingsrc/androidx-stubs/javaprecedent in the same module.
The Java sources are identical either way. Which do you prefer?
There was a problem hiding this comment.
go ahead and fix the ci too, don't worry about removing the jar
There was a problem hiding this comment.
Accepted — CI provisioning comes into this PR, and the checked-in lib/android.jar stays in the tree (it simply stops being referenced).
Queued for the write phase:
jme3-android/build.gradle: dropcompileOnly files(rootProject.file('lib/android.jar'))(both configurations) and use the platformandroid.jarof the locally installed SDK, reusing the discovery already insettings.gradle(findAndroidSdk,androidExamplesMinCompileSdk = 34), with an explicit failure message when no API ≥ 34 platform is installed..github/workflows/main.yml: provisionplatforms;android-34in every job that configures or compilesjme3-android—Checkstyle,SpotBugs,JavadocDoclint,ScreenshotTests,BuildJMonkey(ubuntu/windows/macOS), andDeployJavaSnapshot/DeployRelease(both run a Gradle build onubuntu-latest), since the module is included unconditionally and those jobs currently have no Android SDK.- The reflection-free
AndroidGameMode/GameModerewrite exactly as scoped earlier: directGameManageruse guarded per member (SforgetGameMode()/GAME_MODE_*,TIRAMISUforsetGameState(GameState),UPSIDE_DOWN_CAKEforGAME_MODE_CUSTOM), no listener proxy and no register/unregister, mode read onON_RESUME/onResume()and pushed to the jME listener, null-safe manager lookup,android:isGame="true"in the example manifest.
./gradlew still cannot run here (no Gradle distribution, no Android SDK), so the first push may land red and CI is the real gate on both the classpath change and the rewrite. Write phase requested.
There was a problem hiding this comment.
Accepted — the write phase now covers the review items plus the jar removal:
AndroidGameMode/GameModerewritten reflection-free: directandroid.app.GameManager, guarded per member (SforgetGameMode()/GAME_MODE_*,TIRAMISUforsetGameState(GameState),UPSIDE_DOWN_CAKEforGAME_MODE_CUSTOM), null-safe manager lookup. Listener proxy,register(),unregister()and the re-read-on-registration are dropped; the mode is read on resume (JmeSurfaceViewonON_RESUME,AndroidHarnessFragment.onResume()) and pushed to the jME listener.jme3-android/build.gradle: bothcompileOnly files(rootProject.file('lib/android.jar'))entries go away, replaced by the platform jar of the locally installed SDK, reusingsettings.gradle'sfindAndroidSdkdiscovery and failing with an explicit message when no API ≥ 34 platform is installed.lib/android.jarremoved from the tree. It is a ~47 MB binary and my patch path writes text only, so the actual deletion has to be a hostgit rmin the write phase; the build.gradle change already makes it unreferenced either way..github/workflows/main.yml: provisionplatforms;android-34in every job that configures or compilesjme3-android(Checkstyle,SpotBugs,JavadocDoclint,ScreenshotTests,BuildJMonkeyon ubuntu/windows/macOS,DeployJavaSnapshot,DeployRelease), which currently install no Android SDK and would fail once the jar is gone.android:isGame="true"on the example application so the platform actually reports a mode toTestGameModeActivity.
./gradlew still cannot run here, so CI remains the gate.
There was a problem hiding this comment.
Landed the build/CI half as c111388 on jaime/issue-1995-6df48d20 — Compile jme3-android against the local Android SDK (#1148):
jme3-android/build.gradle: the twocompileOnly files(rootProject.file('lib/android.jar'))entries are gone. The module now resolvesplatforms/android-<n>/android.jar(highest installed platform ≥ API 34) from the locally installed SDK —-Pandroid.sdk.path,sdk.dirinlocal.properties,ANDROID_HOME,ANDROID_SDK_ROOT, or the usual per-OS default locations — and fails with an explicit message naming the missingplatforms;android-34if none is installed. The jar is resolved in a provider, so only tasks that really build this module fail without an SDK.settings.gradleis untouched (the discovery is in the module)..github/workflows/main.yml:android-actions/setup-android@v4.0.4withpackages: 'platforms;android-34'added to Checkstyle, SpotBugs, JavadocDoclint, BuildJMonkey (ubuntu/windows/macOS), DeployJavaSnapshot and DeployRelease.lib/android.jaris still in the tree (the patch path cannot carry the ~46 MB binary), but nothing references it any more — agit rm lib/android.jarfrom a maintainer closes android.jar should be loaded from the local android sdk installation and not shipped with the engine. #1148.
The reflection-free Java half is written and validated in the sandbox but is not on the branch yet. Validation on the current sources: jme3-core and every jme3-android source (including JmeSurfaceView, against the in-tree androidx stubs plus API-34 stand-ins for GameManager/GameState/VERSION_CODES, since the checked-in jar is API 28) compile; the compiled AndroidGameMode contains no java.lang.reflect reference and exposes setGameState(GameState); and the bridge passes runtime checks — notified with the current mode on registration, refresh() reports a new mode, setGameState forwarded on API 33+, API 30 → UNSUPPORTED and no setGameState, unknown platform values → UNSUPPORTED, null GameManager instance handled.
What blocks it: the write tool pins its base at the start of a write phase, so after c111388 every further commit is rejected with requested commit b18fb0a… is not the current head c111388…. I cannot re-pin it from here (no network, no credential in the sandbox). So the remaining files — AndroidGameMode, GameMode, OnGameModeChanged, JmeSurfaceView, AndroidHarnessFragment, TestGameModeActivity and android:isGame="true" in the example manifest, all already prepared and validated — need one more write phase (or a manual commit); as soon as one is triggered they go in unchanged.
One CI caveat to watch on this branch: .github/workflows/android-screenshot.yml builds :jme3-screenshot-tests-android against :jme3-android, so it now needs an API ≥ 34 platform in the runner's SDK as well; if that job fails for lack of platforms;android-34 I will add the same provisioning step there.
There was a problem hiding this comment.
Landed the reflection-free bridge as f893321 on jaime/issue-1995-6df48d20:
AndroidGameModeusesandroid.app.GameManagerdirectly, guarded per member: service lookup +getGameMode()/GAME_MODE_*atS(31),setGameState(GameState)atTIRAMISU(33),GAME_MODE_CUSTOMatUPSIDE_DOWN_CAKE(34). The proxy,register()/unregister()and the re-read on registration are gone;refresh()reads the mode and pushes it to the listener, andsetListener()reports the current mode once on registration.OnGameModeChangedis documented as a jME-side listener that the harnesses refresh — not a platform callback delivered for every settings change.JmeSurfaceViewreads the mode onON_RESUME.
Checked in the sandbox: all jme3-core and jme3-android sources compile; AndroidGameMode contains no java.lang.reflect/Proxy reference and exposes setGameState(GameState); a behavioural matrix against a fake GameManager at API 30/31/33/34 passes — API 30 never looks the service up and reports UNSUPPORTED, setGameState is refused below 33, known and unknown mode values map correctly, and the listener is only notified on registration/refresh.
Still to land — this phase only produced one commit, so these four items need the next write phase:
AndroidHarnessFragment.onResume()refresh.- explicit
CUSTOMcase inTestGameModeActivityplus its doc rewording. android:appCategory="game"on the example application.lib/android.jarremoval (still in the tree, unreferenced sincec111388; the build already resolves the platform jar from the local SDK).
main.yml already provisions platforms;android-34, and android-screenshot.yml was left untouched — its emulator runner installs API 35.
The PR body still describes the old reflection-based bridge. Replacement text, since I can't edit the body from here (it describes the finished rewrite, i.e. including the four items above):
Adds the Android Game-Mode API to
jme3-android(Android 12+), so games can react to the mode the user chose.New public API (
com.jme3.system.android)
GameMode—UNSUPPORTED(0),STANDARD(1),PERFORMANCE(2),BATTERY(3),CUSTOM(4), withisSupported(),getValue()andfromValue(int).OnGameModeChanged— the jME-side listener notified when jME refreshes the mode.AndroidGameMode— thin bridge overandroid.app.GameManagerwith per-member API guards (31/33/34):getGameMode(),refresh(),setGameState(GameState).Wiring —
JmeSurfaceView#setOnGameModeChanged(...)reads the mode onON_RESUME;AndroidHarnessFragment#setOnGameModeChanged(...)reads it inonResume().Compatibility — Android does not notify games of mode changes and nothing runs below the API level that introduced it, so older devices and apps the platform does not treat as games are simply reported as
UNSUPPORTED.Example —
TestGameModeActivitylogs the mode jME reports, with an explicitCUSTOMcase; the manifest declaresandroid:appCategory="game".Build —
jme3-androidcompiles against the platformandroid.jarof the locally installed SDK (≥ API 34, #1148); CI provisionsplatforms;android-34.Fixes #1995.
Trigger the next write phase and I'll push those four items and re-check CI.
| public Object invoke(Object proxy, Method method, Object[] args) { | ||
| if (GAME_MODE_CHANGED_METHOD_NAME.equals(method.getName()) | ||
| && args != null && args.length == 1 && args[0] instanceof Integer) { | ||
| dispatch(GameMode.fromValue((Integer) args[0])); |
There was a problem hiding this comment.
This is nonsense, just use use GameManager.getGameMode() on resume and notify the jme listener. Check android documentation.
There was a problem hiding this comment.
Confirmed, and the impact is bigger than this call site: the platform has no game-mode change listener at all. android.app.GameManager in android-12.0.0_r1 (API 31), android-13.0.0_r1 (API 33), android-14.0.0_r1 (API 34) and current AOSP master declares only getGameMode() (and setGameState() from API 33) — neither GameManager$OnGameModeChangedListener nor registerGameModeChangedListener() exists, and the getGameMode() javadoc says "Developers should call this API every time the application is resumed".
That also means the current code cannot work on any device: the constructor nulls the whole bridge when Class.forName("android.app.GameManager$OnGameModeChangedListener") fails (same try block as the manager lookup), so getGameMode() is never invoked and the API reports UNSUPPORTED even on Android 12+.
The rewrite drops the proxy, register(), unregister() and the re-read-on-registration, and instead reads GameManager.getGameMode() on resume (JmeSurfaceView on ON_RESUME, AndroidHarnessFragment.onResume()) and notifies the listener with that value.
| * saver mode for the game. | ||
| */ | ||
| BATTERY(3); | ||
|
|
There was a problem hiding this comment.
Api 34 supports CUSTOM as 4th value. Verify with android docs
There was a problem hiding this comment.
Correct. GameManager.GAME_MODE_CUSTOM = 4 was added in API 34 (UPSIDE_DOWN_CAKE) and is still the highest value in AOSP master (android-14.0.0_r1 framework source). CUSTOM(4) will be added to the enum, and fromValue() keeps mapping unknown values to UNSUPPORTED. Note the platform only reports CUSTOM for targetSdk ≥ 34, otherwise getGameMode() returns STANDARD for it.
jaime-jmebot
left a comment
There was a problem hiding this comment.
All review items are accepted and tracked as action items for the rewrite; nothing is claimable as fixed yet.
- Reflection removed:
GameManageris used directly, guarded per member (SforgetGameMode()/GAME_MODE_*,TIRAMISUforsetGameState(...),UPSIDE_DOWN_CAKEforGAME_MODE_CUSTOM).jme3-androidcompiles against the checked-inlib/android.jar, so that jar must move to API 34. - The listener cannot be implemented as specified: no game-mode-changed listener exists in the platform API (API 31/33/34 and master), and the current
Class.forNamecoupling makes the bridge always reportUNSUPPORTED. Replaced by readingGameManager.getGameMode()on resume and notifying the listener there. GameModegainsCUSTOM(4)(API 34);fromValue()still maps unknown values toUNSUPPORTED.setGameState()(API 33) will be added, takingandroid.app.GameState— the released signature issetGameState(GameState), not a boolean.- Example:
TestGameModeActivityis wired, but the example app is not declared as a game inAndroidManifest.xml, so the platform always reportsUNSUPPORTEDfor it; the manifest needsandroid:isGame="true".
jaime-jmebot
left a comment
There was a problem hiding this comment.
Replied in the thread and requested the write phase for the accepted changes.
- Direction accepted: resolve #1148 — replace the checked-in
lib/android.jarwith the locally installed SDK platform jar (≥ API 34, reusingsettings.gradle's discovery) and rewrite the bridge reflection-free. - Flagged the two parts outside my reach: deleting the ~47 MB binary jar, and provisioning
platforms;android-34in the engine CI jobs (BuildJMonkey,JavadocDoclint,SpotBugs), which otherwise fail sincejme3-androidis built by every job without an Android SDK. - Asked the one open scope question: CI provisioning inside this PR or separately.
jaime-jmebot
left a comment
There was a problem hiding this comment.
Replied in the thread confirming the accepted scope (CI provisioning inside this PR, jar left untouched) and requested the write phase for the build/CI/rewrite changes.
|
@jaime-jmebot fix the issues with this PR and remove the old android.jar |
jaime-jmebot
left a comment
There was a problem hiding this comment.
Reviewed at b18fb0a. Nothing new was found beyond the tracked items; state of the branch:
GameMode.CUSTOM(4)is the only accepted change actually on the branch.AndroidGameModeis unchanged: it still uses reflection and still probesandroid.app.GameManager$OnGameModeChangedListener, which does not exist in API 31/33/34 or master. That lookup shares atryblock with the manager lookup, so the bridge is nulled andgetGameMode()is never invoked — the API reportsUNSUPPORTEDon every device, including Android 12+.- The mode is only read when a listener is registered; there is no read on resume (
JmeSurfaceView.onStateChanged/ON_RESUME,AndroidHarnessFragment.onResume()), which is what the platform javadoc asks for. GameManager.setGameState(GameState)(API 33) is still absent, as areandroid:isGame="true"on the example application.jme3-android/build.gradlestill resolvesandroid.*from the checked-inlib/android.jar(twocompileOnly files(...)entries), and that jar predates API 31 — the blocker for the reflection-free rewrite. The engine CI jobs install no Android SDK, so removing the jar also requiresplatforms;android-34provisioning in.github/workflows/main.yml.
Write phase requested for the accepted scope (reflection-free bridge, resume-time read, setGameState, SDK-based classpath + jar removal, CI provisioning, isGame="true").
- jme3-android: drop the checked-in lib/android.jar from the compile classpath and use platforms/android-<n>/android.jar (API 34+) of the locally installed SDK - CI: provision platforms;android-34 in the jobs that build jme3-android
|
@jaime-jmebot please start another write phase on top of the current head (
After pushing the Java rewrite, re-check CI and only resolve the existing review threads once the corresponding changes are actually present on the branch. |
|
Thanks — starting another write phase on top of
I'll re-check CI after the push and only resolve review threads whose change is actually on the branch. |
AndroidGameMode calls GameManager directly with per-member SDK guards and the fake platform listener is gone; the mode is read on resume. Docs updated accordingly.
## Why the screenshot pipeline kept going red The intermittent failures are pixel-comparison noise, not a rendering regression. The two Android screenshot jobs that failed recently failed on **unrelated** commits, always on the same test: | run | commit | result | | --- | --- | --- | | 234 | jMonkeyEngine#2961 (Android game-mode API) | failed: `TestExplosionEffect` — "Generated images is different from committed image" | | 236 | jMonkeyEngine#2963, first commit | passed | | 237 | jMonkeyEngine#2963, final commit | failed: `TestExplosionEffect`, after all 3 retry attempts | In both failing runs the changed image pulled off the device is **442,598 bytes for a 1280x800 frame against a 442,536 byte reference** — a handful of pixels apart. And the old comparison was all-or-nothing: the *first* pixel differing by more than 3/255 failed the whole test, and the diff was never reported. That difference is a property of the machine that rendered it. The CI renders with software renderers (Mesa in the desktop job, the emulator's GLES renderer on Android) whose rounding varies from runner to runner, so re-running the test on the same runner reproduces the same mismatch. That is exactly why all three attempts of the retry loop added in jMonkeyEngine#2863 failed: a retry cannot change the host it runs on. The retry loops stay as they are — they do rescue the desktop/ANGLE jobs when the display server or a JVM dies — but they were never going to fix this. ## What changed - **New `ImageDifference`** (`jme3-screenshot-tests-shared/.../testframework/ImageDifference.java`) measures the difference between two images instead of bailing out on the first bad pixel: how many pixels differ by more than 3/255 on any channel, out of how many, and the worst channel difference found. It also owns the pixel maths that used to live in `ScreenshotTest`. - **A small noise budget counts as "the same image"**: at most `max(10, 0.02% of the pixels)` may be outside the per-pixel tolerance — about 40 pixels on a 500x400 desktop screenshot and 204 on a 1280x800 emulator frame. Anything that actually changes what is drawn moves far more pixels than that. - **Failures now say what they measured**, e.g. `Generated images is different from committed image. (5 of 10000 pixels differ by more than 3 (at most 10 tolerated), largest single channel difference 255)` so a real change can be told apart from a rendering hiccup without downloading the artifacts. - `ScreenshotTest` uses the measurement for both the reference comparison and the multi-scenario comparison; the duplicated private `imagesAreVerySimilar`/`getMaximumComponentDifference` implementations are gone. `KNOWN_TO_FAIL`/`NON_DETERMINISTIC` behaviour is unchanged. - **New `ImageDifferenceTest`** covers identical images, tolerated noise, noise over the budget, a 30x30 changed area, the size-dependent budget and the size-mismatch guard. - **README** documents the tolerance, the numbers, and how to read the new failure message. The tolerance constants live in `ImageDifference` (0.02%, minimum 10 pixels, 3/255 per channel) so they are easy to tighten if the numbers ever need revisiting. ## Validation `jme3-core` and the screenshot-test framework compile with `javac`, and the seven new test methods were executed through an equivalent reflective JUnit runner (this environment has no network access, so `./gradlew` cannot fetch the Gradle distribution or the JUnit jars; CI runs the real suite): ``` identical -> tolerated=true | 0 of 100 pixels differ by more than 3 (at most 10 tolerated) 5 noisy pixels -> tolerated=true | 5 of 100 pixels differ by more than 3 (at most 10 tolerated) 11 noisy pixels-> tolerated=false | 11 of 100 pixels differ by more than 3 (at most 10 tolerated) ``` Closes jMonkeyEngine#2964.
* chore: begin work on issue #2964 * Screenshot tests: tolerate a few pixels of renderer noise ## Why the screenshot pipeline kept going red The intermittent failures are pixel-comparison noise, not a rendering regression. The two Android screenshot jobs that failed recently failed on **unrelated** commits, always on the same test: | run | commit | result | | --- | --- | --- | | 234 | #2961 (Android game-mode API) | failed: `TestExplosionEffect` — "Generated images is different from committed image" | | 236 | #2963, first commit | passed | | 237 | #2963, final commit | failed: `TestExplosionEffect`, after all 3 retry attempts | In both failing runs the changed image pulled off the device is **442,598 bytes for a 1280x800 frame against a 442,536 byte reference** — a handful of pixels apart. And the old comparison was all-or-nothing: the *first* pixel differing by more than 3/255 failed the whole test, and the diff was never reported. That difference is a property of the machine that rendered it. The CI renders with software renderers (Mesa in the desktop job, the emulator's GLES renderer on Android) whose rounding varies from runner to runner, so re-running the test on the same runner reproduces the same mismatch. That is exactly why all three attempts of the retry loop added in #2863 failed: a retry cannot change the host it runs on. The retry loops stay as they are — they do rescue the desktop/ANGLE jobs when the display server or a JVM dies — but they were never going to fix this. ## What changed - **New `ImageDifference`** (`jme3-screenshot-tests-shared/.../testframework/ImageDifference.java`) measures the difference between two images instead of bailing out on the first bad pixel: how many pixels differ by more than 3/255 on any channel, out of how many, and the worst channel difference found. It also owns the pixel maths that used to live in `ScreenshotTest`. - **A small noise budget counts as "the same image"**: at most `max(10, 0.02% of the pixels)` may be outside the per-pixel tolerance — about 40 pixels on a 500x400 desktop screenshot and 204 on a 1280x800 emulator frame. Anything that actually changes what is drawn moves far more pixels than that. - **Failures now say what they measured**, e.g. `Generated images is different from committed image. (5 of 10000 pixels differ by more than 3 (at most 10 tolerated), largest single channel difference 255)` so a real change can be told apart from a rendering hiccup without downloading the artifacts. - `ScreenshotTest` uses the measurement for both the reference comparison and the multi-scenario comparison; the duplicated private `imagesAreVerySimilar`/`getMaximumComponentDifference` implementations are gone. `KNOWN_TO_FAIL`/`NON_DETERMINISTIC` behaviour is unchanged. - **New `ImageDifferenceTest`** covers identical images, tolerated noise, noise over the budget, a 30x30 changed area, the size-dependent budget and the size-mismatch guard. - **README** documents the tolerance, the numbers, and how to read the new failure message. The tolerance constants live in `ImageDifference` (0.02%, minimum 10 pixels, 3/255 per channel) so they are easy to tighten if the numbers ever need revisiting. ## Validation `jme3-core` and the screenshot-test framework compile with `javac`, and the seven new test methods were executed through an equivalent reflective JUnit runner (this environment has no network access, so `./gradlew` cannot fetch the Gradle distribution or the JUnit jars; CI runs the real suite): ``` identical -> tolerated=true | 0 of 100 pixels differ by more than 3 (at most 10 tolerated) 5 noisy pixels -> tolerated=true | 5 of 100 pixels differ by more than 3 (at most 10 tolerated) 11 noisy pixels-> tolerated=false | 11 of 100 pixels differ by more than 3 (at most 10 tolerated) ``` Closes #2964.
Tracks #1995
Implementation plan
Progress summary
Implement Android Game-Mode API support in jMonkeyEngine (issue #1995) exactly as specified by the provided file contents. Constraints that must be preserved:
Tests
8 approved test command(s) completed successfully in the sandbox
Result
Adds support for the Android Game-Mode API to
jme3-android, so applications can bind their own logic to the game mode selected by the user (Android 12+).New public API (
com.jme3.system.android)GameMode— enum mirroring the platform constants:UNSUPPORTED(0),STANDARD(1),PERFORMANCE(2),BATTERY(3), plusisSupported(),getValue()andfromValue(int).OnGameModeChanged—void onGameModeChanged(GameMode gameMode). This is the single-listener equivalent of the per-mode callbacks described in the issue:PERFORMANCE~onPerformanceEnabled,BATTERY~onBatterySaverEnabled,STANDARD~onStandardEnabled,UNSUPPORTED~onDisabled.AndroidGameMode— reflection-based bridge toandroid.app.GameManager: reads the current mode, registers/unregisters aGameManager.OnGameModeChangedListenerproxy and re-reads the mode when a listener is registered, so the listener is always notified once with the current mode (includingUNSUPPORTEDon Android 11 and older, or when the platform does not report a game mode). Callbacks are dispatched on the Android main thread.Wiring
JmeSurfaceView#setOnGameModeChanged(OnGameModeChanged)(the class named in the issue), registered lazily from the view context and unregistered indestroy().AndroidHarnessFragment#setOnGameModeChanged(OnGameModeChanged)(the non-deprecated harness): the context is captured inonAttach, the listener is registered immediately when the fragment is already attached, otherwise at the end ofonCreate, and unregistered inonDestroy.Backward compatibility
No
android.app.GameManager,Build.VERSION_CODES.Sor other API-31 symbol is referenced at compile time or in any public signature; all platform access goes throughjava.lang.reflectand every failure degrades gracefully (logged, mode reported asUNSUPPORTED). Devices older than Android 12 keep working unchanged.Example
jme3-android-examples: new self-containedTestGameModeActivity(declared inAndroidManifest.xml) that registers the listener on aJmeSurfaceViewand prints/logs every game mode change, with comments on the kind of logic a game may apply:Validation
The sandbox could not provision the Gradle wrapper distribution nor a pre-populated Gradle cache (
Could not create parent directory for lock file ... gradle-9.4.1-bin.zip.lck), so./gradlewcould not run here; CI is the gate for the Gradle build. Validation performed instead with plain javac:jme3-coresources compiled (src/main/java,src/plugins/java,src/tools/java);jme3-android/src/main/javasources compiled againstlib/android.jar(with the in-tree androidx stubs plus minimal annotation/lifecycle stubs);TestGameModeActivitycompiled against the freshly builtjme3-coreandjme3-androidclasses.Two real compilation problems were found and fixed during this validation: the in-tree
androidx.fragment.app.Fragmentstub has nogetContext()(the harness now uses theonAttachcontext), and the example no longer depends onMainActivity/R.Fixes #1995.