From 20d33c2bf742ec26788efad805b40b5c180bb56a Mon Sep 17 00:00:00 2001
From: Jaime Bot
Date: Thu, 17 Sep 2026 19:01:40 +0200
Subject: [PATCH 1/5] chore: begin work on issue #1995
From 71ce527528723d735e2fbd7d1a3a12ac47f3cfbf Mon Sep 17 00:00:00 2001
From: Jaime Bot
Date: Thu, 17 Sep 2026 19:12:32 +0200
Subject: [PATCH 2/5] Add Android Game-Mode API listener support (#1995)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 #1995.
---
.../src/main/AndroidManifest.xml | 7 +
.../TestGameModeActivity.java | 109 ++++++++
.../com/jme3/app/AndroidHarnessFragment.java | 48 ++++
.../jme3/system/android/AndroidGameMode.java | 240 ++++++++++++++++++
.../com/jme3/system/android/GameMode.java | 110 ++++++++
.../system/android/OnGameModeChanged.java | 66 +++++
.../jme3/view/surfaceview/JmeSurfaceView.java | 35 +++
7 files changed, 615 insertions(+)
create mode 100644 jme3-android-examples/src/main/java/org/jmonkeyengine/jme3androidexamples/TestGameModeActivity.java
create mode 100644 jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java
create mode 100644 jme3-android/src/main/java/com/jme3/system/android/GameMode.java
create mode 100644 jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java
diff --git a/jme3-android-examples/src/main/AndroidManifest.xml b/jme3-android-examples/src/main/AndroidManifest.xml
index 1ec9d5d342..25e766b01f 100644
--- a/jme3-android-examples/src/main/AndroidManifest.xml
+++ b/jme3-android-examples/src/main/AndroidManifest.xml
@@ -39,6 +39,13 @@
android:launchMode="singleTask"
android:screenOrientation="landscape">
+
+
diff --git a/jme3-android-examples/src/main/java/org/jmonkeyengine/jme3androidexamples/TestGameModeActivity.java b/jme3-android-examples/src/main/java/org/jmonkeyengine/jme3androidexamples/TestGameModeActivity.java
new file mode 100644
index 0000000000..2ef21b8504
--- /dev/null
+++ b/jme3-android-examples/src/main/java/org/jmonkeyengine/jme3androidexamples/TestGameModeActivity.java
@@ -0,0 +1,109 @@
+package org.jmonkeyengine.jme3androidexamples;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.RelativeLayout;
+import androidx.fragment.app.FragmentActivity;
+import com.jme3.app.LegacyApplication;
+import com.jme3.system.android.GameMode;
+import com.jme3.system.android.OnGameModeChanged;
+import com.jme3.view.surfaceview.JmeSurfaceView;
+
+/**
+ * Example and verification Activity reporting the Android Game Mode selected by the user.
+ *
+ * It registers an {@link OnGameModeChanged} listener on the {@link JmeSurfaceView} and
+ * logs every game mode change. The same listener is available on
+ * {@code com.jme3.app.AndroidHarnessFragment}.
+ *
+ * The platform only reports a game mode on Android 12 and newer, and only for
+ * applications it treats as games; everywhere else the listener is notified once with
+ * {@link GameMode#UNSUPPORTED}. See the Android documentation for the
+ * Game Mode API.
+ *
+ * Launch it for example with:
+ * {@code adb shell am start -n org.jmonkeyengine.jme3androidexamples/.TestGameModeActivity}
+ * and watch the output with {@code adb logcat -s TestGameModeActivity}. Add
+ * {@code --es Selected_App_Class } to host a different jME application.
+ *
+ * @see GameMode
+ * @see OnGameModeChanged
+ * @see JmeSurfaceView#setOnGameModeChanged(OnGameModeChanged)
+ */
+@SuppressWarnings("deprecation")
+public class TestGameModeActivity extends FragmentActivity {
+
+ /**
+ * Key of the intent extra selecting the jME application to host. It mirrors
+ * {@code MainActivity.SELECTED_APP_CLASS}.
+ */
+ private static final String SELECTED_APP_CLASS = "Selected_App_Class";
+
+ private static final String TAG = "TestGameModeActivity";
+ private static final String DEFAULT_APP_CLASS = "jme3test.android.TestAndroidSensors";
+
+ private JmeSurfaceView jmeSurfaceView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ super.onCreate(savedInstanceState);
+
+ jmeSurfaceView = new JmeSurfaceView(this);
+ jmeSurfaceView.setOnGameModeChanged(new OnGameModeChanged() {
+ @Override
+ public void onGameModeChanged(GameMode gameMode) {
+ /*
+ * The callback is invoked with the current mode as soon as the listener
+ * is registered and afterwards on every change made in the system game
+ * settings. Applications can implement their own logic here, for example
+ * altering the level of detail, loading lower-poly models, changing the
+ * frame rate or disabling filters.
+ */
+ System.out.println("Game mode changed to: " + gameMode);
+ Log.i(TAG, "Game mode changed to: " + gameMode);
+ switch (gameMode) {
+ case PERFORMANCE:
+ // Favor visual quality, for example a higher frame rate.
+ break;
+ case BATTERY:
+ // Save power, for example a lower frame rate and no filters.
+ break;
+ case STANDARD:
+ // Use the regular, balanced settings.
+ break;
+ case UNSUPPORTED:
+ default:
+ // The Game Mode API is not available on this device.
+ break;
+ }
+ }
+ });
+
+ String appClass = DEFAULT_APP_CLASS;
+ Bundle bundle = getIntent().getExtras();
+ if (bundle != null && bundle.containsKey(SELECTED_APP_CLASS)) {
+ appClass = bundle.getString(SELECTED_APP_CLASS);
+ }
+
+ try {
+ Class> clazz = Class.forName(appClass);
+ LegacyApplication app = (LegacyApplication) clazz.getDeclaredConstructor().newInstance();
+ jmeSurfaceView.setLegacyApplication(app);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ getLifecycle().addObserver(jmeSurfaceView);
+
+ RelativeLayout layout = new RelativeLayout(this);
+ layout.addView(jmeSurfaceView);
+ setContentView(layout);
+
+ jmeSurfaceView.startRenderer(0);
+ }
+}
diff --git a/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java b/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java
index 1e2bbd8dd3..6bb7331511 100644
--- a/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java
+++ b/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java
@@ -45,8 +45,11 @@
import com.jme3.input.android.AndroidJoyInput;
import com.jme3.system.AppSettings;
import com.jme3.system.SystemListener;
+import com.jme3.system.android.AndroidGameMode;
+import com.jme3.system.android.GameMode;
import com.jme3.system.android.JmeAndroidSystem;
import com.jme3.system.android.OGLESContext;
+import com.jme3.system.android.OnGameModeChanged;
import com.jme3.util.AndroidLogHandler;
import com.jme3.util.AndroidNativeBufferAllocator;
import com.jme3.util.BufferAllocatorFactory;
@@ -71,10 +74,14 @@ public abstract class AndroidHarnessFragment extends Fragment implements SystemL
protected GLSurfaceView view;
protected LegacyApplication app;
protected boolean finishOnAppStop = true;
+ private Context attachedContext;
+ private AndroidGameMode androidGameMode;
+ private OnGameModeChanged onGameModeChangedListener;
@Override
public void onAttach(Context context) {
super.onAttach(context);
+ attachedContext = context;
}
public Application getJmeApplication() {
@@ -85,6 +92,38 @@ public void setFinishOnAppStop(boolean finishOnAppStop) {
this.finishOnAppStop = finishOnAppStop;
}
+ /**
+ * Registers a listener notified when the Android game mode changes.
+ *
+ * The current game mode is reported to the listener as soon as it is registered,
+ * including once with {@link GameMode#UNSUPPORTED} on devices where the Game Mode API
+ * is unavailable (Android 11 and older) or for applications the platform does not
+ * treat as games. Pass null to unregister a previously registered listener. When this
+ * method is called before the fragment is attached to a context, the listener is
+ * registered as soon as the fragment is created.
+ *
+ * Applications typically use this listener to alter the level of detail, load
+ * lower-poly models, change the frame rate or disable filters when the platform asks
+ * for performance or for battery saving.
+ *
+ * @param onGameModeChanged the listener, or null to unregister
+ * @see GameMode
+ * @see OnGameModeChanged
+ */
+ public void setOnGameModeChanged(OnGameModeChanged onGameModeChanged) {
+ this.onGameModeChangedListener = onGameModeChanged;
+ if (attachedContext != null) {
+ getAndroidGameMode().setListener(onGameModeChanged);
+ }
+ }
+
+ private AndroidGameMode getAndroidGameMode() {
+ if (androidGameMode == null) {
+ androidGameMode = new AndroidGameMode(attachedContext);
+ }
+ return androidGameMode;
+ }
+
@Override
public void onCreate(Bundle savedInstanceState) {
initializeLogHandler();
@@ -106,6 +145,10 @@ public void onCreate(Bundle savedInstanceState) {
} catch (Exception exception) {
handleError("jME application initialization failed", exception);
}
+
+ if (onGameModeChangedListener != null) {
+ getAndroidGameMode().setListener(onGameModeChangedListener);
+ }
}
/**
@@ -166,6 +209,11 @@ public void onDestroyView() {
@Override
public void onDestroy() {
logger.fine("onDestroy");
+ if (androidGameMode != null) {
+ androidGameMode.setListener(null);
+ androidGameMode = null;
+ }
+ attachedContext = null;
if (app != null) {
app.stop(false);
}
diff --git a/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java b/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java
new file mode 100644
index 0000000000..6653bcffd3
--- /dev/null
+++ b/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.system.android;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Reflection-based bridge to the Android Game Mode API of Android 12 (API 31) and newer.
+ *
+ * The platform classes ({@code android.app.GameManager} and its game mode listener)
+ * are accessed through reflection, so this class compiles and runs on older devices. On
+ * devices where the API is unavailable, {@link #isSupported()} returns false,
+ * {@link #getGameMode()} returns {@link GameMode#UNSUPPORTED} and registering a listener
+ * reports {@link GameMode#UNSUPPORTED} once, the equivalent of the disabled game mode.
+ *
+ * Instances are normally created and managed by the Android harnesses, for example
+ * {@code com.jme3.view.surfaceview.JmeSurfaceView} and
+ * {@code com.jme3.app.AndroidHarnessFragment}. Applications that need the raw API can
+ * create their own instance from any {@link Context}.
+ *
+ * @see GameMode
+ * @see OnGameModeChanged
+ */
+public class AndroidGameMode {
+
+ private static final Logger logger = Logger.getLogger(AndroidGameMode.class.getName());
+ private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
+
+ private static final String GAME_MANAGER_CLASS_NAME = "android.app.GameManager";
+ private static final String GAME_MODE_LISTENER_CLASS_NAME = "android.app.GameManager$OnGameModeChangedListener";
+ private static final String GAME_SERVICE_NAME = "game";
+ private static final String GET_GAME_MODE_METHOD_NAME = "getGameMode";
+ private static final String REGISTER_LISTENER_METHOD_NAME = "registerGameModeChangedListener";
+ private static final String UNREGISTER_LISTENER_METHOD_NAME = "unregisterGameModeChangedListener";
+ private static final String GAME_MODE_CHANGED_METHOD_NAME = "onGameModeChanged";
+
+ private final Object gameManager;
+ private final Method getGameModeMethod;
+ private final Method registerListenerMethod;
+ private final Method unregisterListenerMethod;
+ private final Class> listenerClass;
+ private OnGameModeChanged listener;
+ private Object listenerProxy;
+
+ /**
+ * Creates a bridge to the Game Mode API of the given context.
+ *
+ * @param context the Android context used to look up the game service
+ */
+ public AndroidGameMode(Context context) {
+ Object manager = null;
+ Method getGameMode = null;
+ Method registerListener = null;
+ Method unregisterListener = null;
+ Class> gameModeListenerClass = null;
+
+ try {
+ Class> gameManagerClass = Class.forName(GAME_MANAGER_CLASS_NAME);
+ gameModeListenerClass = Class.forName(GAME_MODE_LISTENER_CLASS_NAME);
+ Object service = context.getSystemService(GAME_SERVICE_NAME);
+ if (gameManagerClass.isInstance(service)) {
+ getGameMode = gameManagerClass.getMethod(GET_GAME_MODE_METHOD_NAME);
+ registerListener =
+ gameManagerClass.getMethod(REGISTER_LISTENER_METHOD_NAME, gameModeListenerClass);
+ unregisterListener =
+ gameManagerClass.getMethod(UNREGISTER_LISTENER_METHOD_NAME, gameModeListenerClass);
+ manager = service;
+ }
+ } catch (Throwable throwable) {
+ manager = null;
+ getGameMode = null;
+ registerListener = null;
+ unregisterListener = null;
+ gameModeListenerClass = null;
+ logger.log(Level.FINE, "The Android Game Mode API is not available on this device", throwable);
+ }
+
+ this.gameManager = manager;
+ this.getGameModeMethod = getGameMode;
+ this.registerListenerMethod = registerListener;
+ this.unregisterListenerMethod = unregisterListener;
+ this.listenerClass = gameModeListenerClass;
+ }
+
+ /**
+ * Tests whether the platform Game Mode API is available, which requires Android 12
+ * (API 31) or newer.
+ *
+ * @return true if the game mode can be read and observed, false otherwise
+ */
+ public boolean isSupported() {
+ return gameManager != null;
+ }
+
+ /**
+ * Reads the game mode currently selected for this application.
+ *
+ * @return the current game mode, or {@link GameMode#UNSUPPORTED} if it cannot be read
+ */
+ public GameMode getGameMode() {
+ if (gameManager == null || getGameModeMethod == null) {
+ return GameMode.UNSUPPORTED;
+ }
+ try {
+ Object gameMode = getGameModeMethod.invoke(gameManager);
+ if (gameMode instanceof Integer) {
+ return GameMode.fromValue((Integer) gameMode);
+ }
+ } catch (Throwable throwable) {
+ logger.log(Level.WARNING, "Unable to read the Android game mode", throwable);
+ }
+ return GameMode.UNSUPPORTED;
+ }
+
+ /**
+ * Registers the listener notified when the platform game mode changes, replacing any
+ * previously registered listener.
+ *
+ * The current game mode is reported to the listener immediately after it is
+ * registered, and the callback is always dispatched on the Android main thread. Pass
+ * null to only unregister the previous listener.
+ *
+ * @param listener the listener to notify, or null to unregister
+ * @see OnGameModeChanged
+ */
+ public void setListener(OnGameModeChanged listener) {
+ unregister();
+ this.listener = listener;
+ if (listener == null) {
+ return;
+ }
+ register(listener);
+ dispatch(getGameMode());
+ }
+
+ /**
+ * Registers the platform listener. Does nothing if the Game Mode API is unavailable.
+ */
+ private void register(OnGameModeChanged listener) {
+ if (gameManager == null || registerListenerMethod == null || listenerClass == null) {
+ return;
+ }
+
+ InvocationHandler handler = new InvocationHandler() {
+ @Override
+ 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]));
+ }
+ return null;
+ }
+ };
+
+ try {
+ listenerProxy = Proxy.newProxyInstance(
+ AndroidGameMode.class.getClassLoader(), new Class>[]{listenerClass}, handler);
+ registerListenerMethod.invoke(gameManager, listenerProxy);
+ } catch (Throwable throwable) {
+ listenerProxy = null;
+ logger.log(Level.WARNING, "Unable to register the Android game mode listener", throwable);
+ }
+ }
+
+ /**
+ * Unregisters the platform listener, if any.
+ */
+ private void unregister() {
+ if (gameManager != null && listenerProxy != null && unregisterListenerMethod != null) {
+ try {
+ unregisterListenerMethod.invoke(gameManager, listenerProxy);
+ } catch (Throwable throwable) {
+ logger.log(Level.WARNING, "Unable to unregister the Android game mode listener", throwable);
+ }
+ }
+ listenerProxy = null;
+ }
+
+ /**
+ * Notifies the registered listener on the Android main thread.
+ */
+ private void dispatch(final GameMode gameMode) {
+ if (listener == null) {
+ return;
+ }
+
+ Runnable notification = new Runnable() {
+ @Override
+ public void run() {
+ OnGameModeChanged target = listener;
+ if (target != null) {
+ target.onGameModeChanged(gameMode);
+ }
+ }
+ };
+
+ Looper looper = Looper.myLooper();
+ if (looper != null && looper == Looper.getMainLooper()) {
+ notification.run();
+ } else {
+ MAIN_HANDLER.post(notification);
+ }
+ }
+}
diff --git a/jme3-android/src/main/java/com/jme3/system/android/GameMode.java b/jme3-android/src/main/java/com/jme3/system/android/GameMode.java
new file mode 100644
index 0000000000..6303d8fa0b
--- /dev/null
+++ b/jme3-android/src/main/java/com/jme3/system/android/GameMode.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.system.android;
+
+/**
+ * The Android game modes reported by the platform Game Mode API of Android 12 (API 31)
+ * and newer.
+ *
+ * The constants mirror the values of {@code android.app.GameManager} so that
+ * applications can react to the mode selected by the user in the system game
+ * settings without depending on API 31 at compile time or on older devices.
+ *
+ * @see AndroidGameMode
+ * @see OnGameModeChanged
+ */
+public enum GameMode {
+
+ /**
+ * No game mode is reported by the system. This is the mode returned on devices
+ * running Android 11 or older and for applications the platform does not treat
+ * as games.
+ */
+ UNSUPPORTED(0),
+
+ /**
+ * The system reports the standard mode, a balanced trade-off between performance
+ * and battery life.
+ */
+ STANDARD(1),
+
+ /**
+ * The system asks for maximum performance, for example when the user enabled
+ * performance mode for the game.
+ */
+ PERFORMANCE(2),
+
+ /**
+ * The system asks for battery saving, for example when the user enabled battery
+ * saver mode for the game.
+ */
+ BATTERY(3);
+
+ private final int value;
+
+ GameMode(int value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns the raw value used by the Android platform for this game mode.
+ *
+ * @return the {@code android.app.GameManager} constant value
+ */
+ public int getValue() {
+ return value;
+ }
+
+ /**
+ * Tests whether the platform actually reports a game mode.
+ *
+ * @return false if this is {@link #UNSUPPORTED}, true otherwise
+ */
+ public boolean isSupported() {
+ return this != UNSUPPORTED;
+ }
+
+ /**
+ * Converts a raw Android game mode value into a {@link GameMode} constant.
+ *
+ * @param value the {@code android.app.GameManager} constant value
+ * @return the matching game mode, or {@link #UNSUPPORTED} for unknown values
+ */
+ public static GameMode fromValue(int value) {
+ for (GameMode gameMode : values()) {
+ if (gameMode.value == value) {
+ return gameMode;
+ }
+ }
+ return UNSUPPORTED;
+ }
+}
diff --git a/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java b/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java
new file mode 100644
index 0000000000..a9b888a223
--- /dev/null
+++ b/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2009-2026 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.system.android;
+
+/**
+ * Listener notified when the Android platform game mode changes.
+ *
+ * The listener is registered with
+ * {@link AndroidGameMode#setListener(OnGameModeChanged)} and is usually exposed by the
+ * Android harnesses, for example
+ * {@code com.jme3.view.surfaceview.JmeSurfaceView#setOnGameModeChanged(OnGameModeChanged)}
+ * and
+ * {@code com.jme3.app.AndroidHarnessFragment#setOnGameModeChanged(OnGameModeChanged)}.
+ *
+ * It maps the per-mode callbacks of the Game Mode API to a single game mode
+ * value: {@link GameMode#PERFORMANCE} corresponds to the performance callback,
+ * {@link GameMode#BATTERY} to the battery saver callback,
+ * {@link GameMode#STANDARD} to the standard callback and
+ * {@link GameMode#UNSUPPORTED} to the disabled callback.
+ *
+ * Callbacks are delivered on the Android main thread. The current mode is reported
+ * to the listener as soon as it is registered, including once with
+ * {@link GameMode#UNSUPPORTED} when the Game Mode API is unavailable.
+ *
+ * @see GameMode
+ * @see AndroidGameMode
+ */
+public interface OnGameModeChanged {
+
+ /**
+ * Invoked when the platform game mode changes, and once with the current mode when
+ * the listener is registered.
+ *
+ * @param gameMode the current game mode, never null
+ */
+ void onGameModeChanged(GameMode gameMode);
+}
diff --git a/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java b/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java
index 8084a08e4c..3823284b35 100644
--- a/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java
+++ b/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java
@@ -56,8 +56,11 @@
import com.jme3.input.android.AndroidSensorJoyInput;
import com.jme3.system.AppSettings;
import com.jme3.system.SystemListener;
+import com.jme3.system.android.AndroidGameMode;
+import com.jme3.system.android.GameMode;
import com.jme3.system.android.JmeAndroidSystem;
import com.jme3.system.android.OGLESContext;
+import com.jme3.system.android.OnGameModeChanged;
import com.jme3.util.AndroidNativeBufferAllocator;
import com.jme3.util.BufferAllocatorFactory;
import java.io.PrintWriter;
@@ -136,6 +139,7 @@ protected static void setFirstUpdatePassed(boolean firstUpdatePassed) {
private OnRendererCompleted onRendererCompleted;
private OnLayoutDrawn onLayoutDrawn;
private OnExceptionThrown onExceptionThrown;
+ private AndroidGameMode androidGameMode;
public JmeSurfaceView(@NonNull Context context) {
super(context);
@@ -414,6 +418,10 @@ public void loseFocus() {
@Override
public void destroy() {
logger.fine("destroy");
+ if (androidGameMode != null) {
+ androidGameMode.setListener(null);
+ androidGameMode = null;
+ }
if (legacyApplication != null) {
legacyApplication.stop(false);
}
@@ -650,6 +658,33 @@ public void setOnLayoutDrawn(OnLayoutDrawn onLayoutDrawn) {
this.onLayoutDrawn = onLayoutDrawn;
}
+ /**
+ * Registers a listener notified when the Android game mode changes.
+ *
+ * The current game mode is reported to the listener as soon as it is registered,
+ * including once with {@link GameMode#UNSUPPORTED} on devices where the Game Mode API
+ * is unavailable (Android 11 and older) or for applications the platform does not
+ * treat as games. Pass null to unregister a previously registered listener.
+ *
+ * Applications typically use this listener to alter the level of detail, load
+ * lower-poly models, change the frame rate or disable filters when the platform asks
+ * for performance or for battery saving.
+ *
+ * @param onGameModeChanged the listener, or null to unregister
+ * @see GameMode
+ * @see OnGameModeChanged
+ */
+ public void setOnGameModeChanged(OnGameModeChanged onGameModeChanged) {
+ getAndroidGameMode().setListener(onGameModeChanged);
+ }
+
+ private AndroidGameMode getAndroidGameMode() {
+ if (androidGameMode == null) {
+ androidGameMode = new AndroidGameMode(getContext());
+ }
+ return androidGameMode;
+ }
+
public String getGlEsVersion() {
return glEsVersion;
}
From b18fb0ada5d0867f6de6784310090b98e3ba2e57 Mon Sep 17 00:00:00 2001
From: Jaime Bot
Date: Thu, 17 Sep 2026 20:00:48 +0200
Subject: [PATCH 3/5] Add GameMode.CUSTOM for the Android 14 (API 34) game mode
constant
---
.../java/com/jme3/system/android/GameMode.java | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/jme3-android/src/main/java/com/jme3/system/android/GameMode.java b/jme3-android/src/main/java/com/jme3/system/android/GameMode.java
index 6303d8fa0b..10cb6c8e00 100644
--- a/jme3-android/src/main/java/com/jme3/system/android/GameMode.java
+++ b/jme3-android/src/main/java/com/jme3/system/android/GameMode.java
@@ -67,7 +67,16 @@ public enum GameMode {
* The system asks for battery saving, for example when the user enabled battery
* saver mode for the game.
*/
- BATTERY(3);
+ BATTERY(3),
+
+ /**
+ * A custom game mode reported by the system on Android 14 (API 34) and newer.
+ *
+ * This is the fourth game mode constant of {@code android.app.GameManager}. The
+ * platform reports it to applications it treats as games, when the user selected the
+ * custom game mode for them.
+ */
+ CUSTOM(4);
private final int value;
@@ -97,7 +106,9 @@ public boolean isSupported() {
* Converts a raw Android game mode value into a {@link GameMode} constant.
*
* @param value the {@code android.app.GameManager} constant value
- * @return the matching game mode, or {@link #UNSUPPORTED} for unknown values
+ * @return the matching game mode, or {@link #UNSUPPORTED} for values this library
+ * does not know about, including game modes added by platform versions newer
+ * than this library
*/
public static GameMode fromValue(int value) {
for (GameMode gameMode : values()) {
From c11138828b117a2d1a8fdeb444d1c95aa6c1b352 Mon Sep 17 00:00:00 2001
From: Jaime Bot
Date: Fri, 18 Sep 2026 00:20:54 +0200
Subject: [PATCH 4/5] Compile jme3-android against the local Android SDK
(#1148)
- jme3-android: drop the checked-in lib/android.jar from the compile classpath and
use platforms/android-/android.jar (API 34+) of the locally installed SDK
- CI: provision platforms;android-34 in the jobs that build jme3-android
---
.github/workflows/main.yml | 36 ++++++++++++++++++++++
jme3-android/build.gradle | 63 ++++++++++++++++++++++++++++++++++++--
2 files changed, 97 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 724d1543f0..1fc2d1bc51 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -68,6 +68,12 @@ jobs:
java-version: '25'
- name: Validate the Gradle wrapper
uses: gradle/actions/wrapper-validation@v6.3.0
+ # jme3-android compiles against the platform android.jar of the Android SDK
+ - name: Set up the Android SDK
+ uses: android-actions/setup-android@v4.0.4
+ with:
+ packages: 'platforms;android-34'
+
- name: Run Checkstyle
run: |
./gradlew checkstyleMain checkstyleTest --console=plain --stacktrace
@@ -95,6 +101,12 @@ jobs:
java-version: '25'
- name: Validate the Gradle wrapper
uses: gradle/actions/wrapper-validation@v6.3.0
+ # jme3-android compiles against the platform android.jar of the Android SDK
+ - name: Set up the Android SDK
+ uses: android-actions/setup-android@v4.0.4
+ with:
+ packages: 'platforms;android-34'
+
- name: Run SpotBugs
run: |
./gradlew -PenableSpotBugs=true spotbugsMain spotbugsTest --console=plain --stacktrace
@@ -122,6 +134,12 @@ jobs:
java-version: '25'
- name: Validate the Gradle wrapper
uses: gradle/actions/wrapper-validation@v6.3.0
+ # jme3-android compiles against the platform android.jar of the Android SDK
+ - name: Set up the Android SDK
+ uses: android-actions/setup-android@v4.0.4
+ with:
+ packages: 'platforms;android-34'
+
- name: Run Javadoc doclint
run: |
./gradlew -PenableJavadocError=true javadoc mergedJavadoc --console=plain --stacktrace
@@ -341,6 +359,12 @@ jobs:
- name: Validate the Gradle wrapper
uses: gradle/actions/wrapper-validation@v6.3.0
+ # jme3-android compiles against the platform android.jar of the Android SDK
+ - name: Set up the Android SDK
+ uses: android-actions/setup-android@v4.0.4
+ with:
+ packages: 'platforms;android-34'
+
- name: Build Engine
shell: bash
run: |
@@ -424,6 +448,12 @@ jobs:
distribution: 'temurin'
java-version: '25'
+ # jme3-android compiles against the platform android.jar of the Android SDK
+ - name: Set up the Android SDK
+ uses: android-actions/setup-android@v4.0.4
+ with:
+ packages: 'platforms;android-34'
+
- name: Rebuild the maven artifacts and upload them to Sonatype's maven-snapshots repo
env:
ORG_GRADLE_PROJECT_centralUsername: ${{ secrets.CENTRAL_USERNAME }}
@@ -489,6 +519,12 @@ jobs:
name: release
path: dist/release
+ # jme3-android compiles against the platform android.jar of the Android SDK
+ - name: Set up the Android SDK
+ uses: android-actions/setup-android@v4.0.4
+ with:
+ packages: 'platforms;android-34'
+
- name: Rebuild the maven artifacts and close the Sonatype staging repository
env:
ORG_GRADLE_PROJECT_centralUsername: ${{ secrets.CENTRAL_USERNAME }}
diff --git a/jme3-android/build.gradle b/jme3-android/build.gradle
index fa8c702555..f43f6eddf5 100644
--- a/jme3-android/build.gradle
+++ b/jme3-android/build.gradle
@@ -6,15 +6,74 @@ sourceSets {
}
}
+// The android.* classes come from the platform android.jar of the locally installed Android
+// SDK, see https://github.com/jMonkeyEngine/jmonkeyengine/issues/1148. The SDK is looked up
+// the same way settings.gradle does it for the Android examples, and the jar is resolved in a
+// provider so that the build only fails when this module is really compiled (or its javadoc
+// or its classpath is built) instead of on every Gradle invocation.
+def androidMinCompileSdk = 34
+def findAndroidSdk = {
+ def sdkDirs = []
+
+ if (project.hasProperty('android.sdk.path')) {
+ sdkDirs << file(project.property('android.sdk.path'))
+ }
+
+ def localProperties = rootProject.file('local.properties')
+ if (localProperties.isFile()) {
+ Properties properties = new Properties()
+ localProperties.withInputStream { properties.load(it) }
+ if (properties.getProperty('sdk.dir')) {
+ sdkDirs << file(properties.getProperty('sdk.dir'))
+ }
+ }
+
+ if (System.env.ANDROID_HOME) {
+ sdkDirs << file(System.env.ANDROID_HOME)
+ }
+ if (System.env.ANDROID_SDK_ROOT) {
+ sdkDirs << file(System.env.ANDROID_SDK_ROOT)
+ }
+ sdkDirs << file("${System.properties['user.home']}/Android/Sdk")
+ sdkDirs << file("${System.properties['user.home']}/Library/Android/sdk")
+ sdkDirs << file("${System.properties['user.home']}/AppData/Local/Android/Sdk")
+
+ for (sdkDir in sdkDirs.unique { it.absolutePath }) {
+ def compileSdk = new File(sdkDir, 'platforms').listFiles()?.collect { platform ->
+ def matcher = platform.name =~ /^android-(\d+)$/
+ matcher.matches() && new File(platform, 'android.jar').isFile() ? matcher[0][1] as int : null
+ }?.findAll { it != null }?.max()
+
+ if (sdkDir.isDirectory() && compileSdk != null && compileSdk >= androidMinCompileSdk) {
+ return [dir: sdkDir, compileSdk: compileSdk]
+ }
+ }
+
+ return null
+}
+
+def androidJar = providers.provider {
+ def androidSdk = findAndroidSdk()
+ if (androidSdk == null) {
+ throw new GradleException(
+ "No Android SDK with API level ${androidMinCompileSdk} or newer was found. "
+ + "jme3-android compiles against the platform android.jar shipped with the Android SDK: "
+ + "install it with 'sdkmanager \"platforms;android-${androidMinCompileSdk}\"' and make the "
+ + "SDK discoverable through the 'android.sdk.path' project property, 'sdk.dir' in "
+ + "local.properties, or the ANDROID_HOME / ANDROID_SDK_ROOT environment variables.")
+ }
+ return new File(androidSdk.dir, "platforms/android-${androidSdk.compileSdk}/android.jar")
+}
+
dependencies {
//added annotations used by JmeSurfaceView.
compileOnly libs.androidx.annotation
compileOnly libs.androidx.lifecycle.common
compileOnly sourceSets.androidxStubs.output
- androidxStubsCompileOnly files(rootProject.file('lib/android.jar'))
+ androidxStubsCompileOnly files(androidJar)
api project(':jme3-core')
implementation libs.jme3.android.natives
- compileOnly files(rootProject.file('lib/android.jar'))
+ compileOnly files(androidJar)
}
compileJava {
From f8933216b98d6a70ef68bdb7fcc5a801862a1203 Mon Sep 17 00:00:00 2001
From: Jaime Bot
Date: Fri, 18 Sep 2026 14:46:32 +0200
Subject: [PATCH 5/5] Make the Android Game Mode bridge reflection-free (#1995)
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.
---
.../jme3/system/android/AndroidGameMode.java | 216 +++++++-----------
.../system/android/OnGameModeChanged.java | 31 +--
.../jme3/view/surfaceview/JmeSurfaceView.java | 17 +-
3 files changed, 111 insertions(+), 153 deletions(-)
diff --git a/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java b/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java
index 6653bcffd3..7ad995118c 100644
--- a/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java
+++ b/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java
@@ -31,23 +31,31 @@
*/
package com.jme3.system.android;
+import android.app.GameManager;
+import android.app.GameState;
import android.content.Context;
-import android.os.Handler;
-import android.os.Looper;
-import java.lang.reflect.InvocationHandler;
-import java.lang.reflect.Method;
-import java.lang.reflect.Proxy;
+import android.os.Build;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
- * Reflection-based bridge to the Android Game Mode API of Android 12 (API 31) and newer.
+ * Bridge to the Android Game Mode API of Android 12 (API 31) and newer.
*
- * The platform classes ({@code android.app.GameManager} and its game mode listener)
- * are accessed through reflection, so this class compiles and runs on older devices. On
- * devices where the API is unavailable, {@link #isSupported()} returns false,
- * {@link #getGameMode()} returns {@link GameMode#UNSUPPORTED} and registering a listener
- * reports {@link GameMode#UNSUPPORTED} once, the equivalent of the disabled game mode.
+ * {@link GameManager} is used directly, with the API level guarded per member:
+ * looking the service up and calling {@code getGameMode()} require API 31
+ * ({@link Build.VERSION_CODES#S}), {@link GameManager#setGameState(GameState)}
+ * requires API 33 ({@link Build.VERSION_CODES#TIRAMISU}) and the custom game mode
+ * requires API 34 ({@link Build.VERSION_CODES#UPSIDE_DOWN_CAKE}). On older devices
+ * {@link #isSupported()} returns false, {@link #getGameMode()} returns
+ * {@link GameMode#UNSUPPORTED} and there is nothing to unregister, so applications
+ * keep working unchanged.
+ *
+ * The platform has no game mode change callback: its documentation asks
+ * applications to read {@code GameManager.getGameMode()} every time they are
+ * resumed. On this API that means storing an {@link OnGameModeChanged} listener and
+ * calling {@link #refresh()} when the application is resumed, which is what the jME
+ * harnesses do. The listener receives the mode currently reported by the system, or
+ * {@link GameMode#UNSUPPORTED} when there is none.
*
* Instances are normally created and managed by the Android harnesses, for example
* {@code com.jme3.view.surfaceview.JmeSurfaceView} and
@@ -60,69 +68,37 @@
public class AndroidGameMode {
private static final Logger logger = Logger.getLogger(AndroidGameMode.class.getName());
- private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
-
- private static final String GAME_MANAGER_CLASS_NAME = "android.app.GameManager";
- private static final String GAME_MODE_LISTENER_CLASS_NAME = "android.app.GameManager$OnGameModeChangedListener";
- private static final String GAME_SERVICE_NAME = "game";
- private static final String GET_GAME_MODE_METHOD_NAME = "getGameMode";
- private static final String REGISTER_LISTENER_METHOD_NAME = "registerGameModeChangedListener";
- private static final String UNREGISTER_LISTENER_METHOD_NAME = "unregisterGameModeChangedListener";
- private static final String GAME_MODE_CHANGED_METHOD_NAME = "onGameModeChanged";
- private final Object gameManager;
- private final Method getGameModeMethod;
- private final Method registerListenerMethod;
- private final Method unregisterListenerMethod;
- private final Class> listenerClass;
+ private final GameManager gameManager;
private OnGameModeChanged listener;
- private Object listenerProxy;
/**
* Creates a bridge to the Game Mode API of the given context.
*
+ *
The game service is only looked up on Android 12 and newer; on any other
+ * device, and when the platform does not publish a {@link GameManager}, the bridge
+ * simply reports {@link GameMode#UNSUPPORTED}.
+ *
* @param context the Android context used to look up the game service
*/
public AndroidGameMode(Context context) {
- Object manager = null;
- Method getGameMode = null;
- Method registerListener = null;
- Method unregisterListener = null;
- Class> gameModeListenerClass = null;
-
- try {
- Class> gameManagerClass = Class.forName(GAME_MANAGER_CLASS_NAME);
- gameModeListenerClass = Class.forName(GAME_MODE_LISTENER_CLASS_NAME);
- Object service = context.getSystemService(GAME_SERVICE_NAME);
- if (gameManagerClass.isInstance(service)) {
- getGameMode = gameManagerClass.getMethod(GET_GAME_MODE_METHOD_NAME);
- registerListener =
- gameManagerClass.getMethod(REGISTER_LISTENER_METHOD_NAME, gameModeListenerClass);
- unregisterListener =
- gameManagerClass.getMethod(UNREGISTER_LISTENER_METHOD_NAME, gameModeListenerClass);
- manager = service;
+ GameManager manager = null;
+ if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ try {
+ manager = context.getSystemService(GameManager.class);
+ } catch (Throwable throwable) {
+ logger.log(Level.FINE,
+ "The Android Game Mode API is not available on this device", throwable);
}
- } catch (Throwable throwable) {
- manager = null;
- getGameMode = null;
- registerListener = null;
- unregisterListener = null;
- gameModeListenerClass = null;
- logger.log(Level.FINE, "The Android Game Mode API is not available on this device", throwable);
}
-
this.gameManager = manager;
- this.getGameModeMethod = getGameMode;
- this.registerListenerMethod = registerListener;
- this.unregisterListenerMethod = unregisterListener;
- this.listenerClass = gameModeListenerClass;
}
/**
* Tests whether the platform Game Mode API is available, which requires Android 12
- * (API 31) or newer.
+ * (API 31) or newer and a device that publishes the game service.
*
- * @return true if the game mode can be read and observed, false otherwise
+ * @return true if the game mode can be read and reported, false otherwise
*/
public boolean isSupported() {
return gameManager != null;
@@ -131,110 +107,84 @@ public boolean isSupported() {
/**
* Reads the game mode currently selected for this application.
*
- * @return the current game mode, or {@link GameMode#UNSUPPORTED} if it cannot be read
+ * @return the current game mode, or {@link GameMode#UNSUPPORTED} if the API is
+ * unavailable or the platform does not report a game mode
*/
public GameMode getGameMode() {
- if (gameManager == null || getGameModeMethod == null) {
+ if (gameManager == null) {
return GameMode.UNSUPPORTED;
}
try {
- Object gameMode = getGameModeMethod.invoke(gameManager);
- if (gameMode instanceof Integer) {
- return GameMode.fromValue((Integer) gameMode);
+ int gameMode = gameManager.getGameMode();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ && gameMode == GameManager.GAME_MODE_CUSTOM) {
+ return GameMode.CUSTOM;
}
+ return GameMode.fromValue(gameMode);
} catch (Throwable throwable) {
logger.log(Level.WARNING, "Unable to read the Android game mode", throwable);
+ return GameMode.UNSUPPORTED;
}
- return GameMode.UNSUPPORTED;
}
/**
- * Registers the listener notified when the platform game mode changes, replacing any
- * previously registered listener.
+ * Reports how much of the current game is actually content versus an interruption,
+ * which lets the platform withhold game mode interventions while gameplay must not
+ * be disturbed. See the Android documentation of
+ * {@link GameManager#setGameState(GameState)} for the available states.
*
- * The current game mode is reported to the listener immediately after it is
- * registered, and the callback is always dispatched on the Android main thread. Pass
- * null to only unregister the previous listener.
+ * This is a no-op that returns false before Android 13 (API 33) and on devices
+ * without the Game Mode API.
*
- * @param listener the listener to notify, or null to unregister
- * @see OnGameModeChanged
- */
- public void setListener(OnGameModeChanged listener) {
- unregister();
- this.listener = listener;
- if (listener == null) {
- return;
- }
- register(listener);
- dispatch(getGameMode());
- }
-
- /**
- * Registers the platform listener. Does nothing if the Game Mode API is unavailable.
+ * @param gameState the state built with a {@code GameState.Builder}
+ * @return true if the state was reported to the platform, false otherwise
*/
- private void register(OnGameModeChanged listener) {
- if (gameManager == null || registerListenerMethod == null || listenerClass == null) {
- return;
+ public boolean setGameState(GameState gameState) {
+ if (gameManager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ return false;
}
-
- InvocationHandler handler = new InvocationHandler() {
- @Override
- 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]));
- }
- return null;
- }
- };
-
try {
- listenerProxy = Proxy.newProxyInstance(
- AndroidGameMode.class.getClassLoader(), new Class>[]{listenerClass}, handler);
- registerListenerMethod.invoke(gameManager, listenerProxy);
+ gameManager.setGameState(gameState);
+ return true;
} catch (Throwable throwable) {
- listenerProxy = null;
- logger.log(Level.WARNING, "Unable to register the Android game mode listener", throwable);
+ logger.log(Level.WARNING, "Unable to report the Android game state", throwable);
+ return false;
}
}
/**
- * Unregisters the platform listener, if any.
+ * Sets the listener notified when this bridge refreshes the current game mode,
+ * replacing any previously registered listener.
+ *
+ * The current game mode is reported to a new listener immediately, including
+ * once with {@link GameMode#UNSUPPORTED} when the Game Mode API is unavailable.
+ * Pass null to unregister the previous listener.
+ *
+ * @param listener the listener to notify, or null to unregister
+ * @see OnGameModeChanged
+ * @see #refresh()
*/
- private void unregister() {
- if (gameManager != null && listenerProxy != null && unregisterListenerMethod != null) {
- try {
- unregisterListenerMethod.invoke(gameManager, listenerProxy);
- } catch (Throwable throwable) {
- logger.log(Level.WARNING, "Unable to unregister the Android game mode listener", throwable);
- }
+ public void setListener(OnGameModeChanged listener) {
+ this.listener = listener;
+ if (listener != null) {
+ refresh();
}
- listenerProxy = null;
}
/**
- * Notifies the registered listener on the Android main thread.
+ * Reads the current game mode and pushes it to the registered listener, if any.
+ *
+ * Because the platform does not notify applications of game mode changes, this
+ * is meant to be called whenever the application comes back to the foreground, for
+ * example from {@code JmeSurfaceView} on {@code ON_RESUME} and from
+ * {@code AndroidHarnessFragment#onResume()}.
+ *
+ * @see #getGameMode()
*/
- private void dispatch(final GameMode gameMode) {
- if (listener == null) {
- return;
- }
-
- Runnable notification = new Runnable() {
- @Override
- public void run() {
- OnGameModeChanged target = listener;
- if (target != null) {
- target.onGameModeChanged(gameMode);
- }
- }
- };
-
- Looper looper = Looper.myLooper();
- if (looper != null && looper == Looper.getMainLooper()) {
- notification.run();
- } else {
- MAIN_HANDLER.post(notification);
+ public void refresh() {
+ OnGameModeChanged target = listener;
+ if (target != null) {
+ target.onGameModeChanged(getGameMode());
}
}
}
diff --git a/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java b/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java
index a9b888a223..9a8a2652e8 100644
--- a/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java
+++ b/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java
@@ -32,24 +32,25 @@
package com.jme3.system.android;
/**
- * Listener notified when the Android platform game mode changes.
+ * jME listener that receives the game mode the Android platform currently reports.
*
- * The listener is registered with
- * {@link AndroidGameMode#setListener(OnGameModeChanged)} and is usually exposed by the
- * Android harnesses, for example
+ *
Android does not offer a game mode change callback: its documentation asks games
+ * to read {@code GameManager.getGameMode()} every time they are resumed. This listener
+ * is therefore notified when jME refreshes that value, not by a platform callback for
+ * every settings change. It is usually registered through the Android harnesses, for
+ * example
* {@code com.jme3.view.surfaceview.JmeSurfaceView#setOnGameModeChanged(OnGameModeChanged)}
* and
- * {@code com.jme3.app.AndroidHarnessFragment#setOnGameModeChanged(OnGameModeChanged)}.
+ * {@code com.jme3.app.AndroidHarnessFragment#setOnGameModeChanged(OnGameModeChanged)},
+ * which refresh it when they are resumed.
*
- * It maps the per-mode callbacks of the Game Mode API to a single game mode
- * value: {@link GameMode#PERFORMANCE} corresponds to the performance callback,
- * {@link GameMode#BATTERY} to the battery saver callback,
- * {@link GameMode#STANDARD} to the standard callback and
- * {@link GameMode#UNSUPPORTED} to the disabled callback.
+ * The listener receives a single game mode value: {@link GameMode#PERFORMANCE},
+ * {@link GameMode#BATTERY}, {@link GameMode#STANDARD} or {@link GameMode#CUSTOM} when
+ * the platform reports one, and {@link GameMode#UNSUPPORTED} when the Game Mode API is
+ * unavailable or the platform has no mode for the application.
*
- * Callbacks are delivered on the Android main thread. The current mode is reported
- * to the listener as soon as it is registered, including once with
- * {@link GameMode#UNSUPPORTED} when the Game Mode API is unavailable.
+ * The listener is notified on the Android main thread, once with the current mode
+ * when it is registered and again whenever jME refreshes that mode.
*
* @see GameMode
* @see AndroidGameMode
@@ -57,8 +58,8 @@
public interface OnGameModeChanged {
/**
- * Invoked when the platform game mode changes, and once with the current mode when
- * the listener is registered.
+ * Invoked with the game mode the platform currently reports, when the listener is
+ * registered and whenever jME refreshes that mode.
*
* @param gameMode the current game mode, never null
*/
diff --git a/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java b/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java
index 3823284b35..0e5846a720 100644
--- a/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java
+++ b/jme3-android/src/main/java/com/jme3/view/surfaceview/JmeSurfaceView.java
@@ -280,6 +280,10 @@ public void run() {
@Override
public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) {
+ if (event == Lifecycle.Event.ON_RESUME && androidGameMode != null) {
+ // The platform asks games to read the game mode every time they are resumed.
+ androidGameMode.refresh();
+ }
if (!bindAppState) {
return;
}
@@ -659,12 +663,15 @@ public void setOnLayoutDrawn(OnLayoutDrawn onLayoutDrawn) {
}
/**
- * Registers a listener notified when the Android game mode changes.
+ * Registers a listener that receives the game mode the Android platform currently
+ * reports.
*
- * The current game mode is reported to the listener as soon as it is registered,
- * including once with {@link GameMode#UNSUPPORTED} on devices where the Game Mode API
- * is unavailable (Android 11 and older) or for applications the platform does not
- * treat as games. Pass null to unregister a previously registered listener.
+ * The platform has no game mode change callback, so the listener is notified on
+ * registration and every time this view is resumed, which is when the system asks
+ * games to read the mode again. It is notified with {@link GameMode#UNSUPPORTED} on
+ * devices where the Game Mode API is unavailable (Android 11 and older) or for
+ * applications the platform does not treat as games. Pass null to unregister a
+ * previously registered listener.
*
* Applications typically use this listener to alter the level of detail, load
* lower-poly models, change the frame rate or disable filters when the platform asks