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-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">
+
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
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..7ad995118c --- /dev/null +++ b/jme3-android/src/main/java/com/jme3/system/android/AndroidGameMode.java @@ -0,0 +1,190 @@ +/* + * 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.app.GameManager; +import android.app.GameState; +import android.content.Context; +import android.os.Build; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Bridge to the Android Game Mode API of Android 12 (API 31) and newer. + * + *{@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 + * {@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 final GameManager gameManager; + private OnGameModeChanged listener; + + /** + * 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) { + 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); + } + } + this.gameManager = manager; + } + + /** + * Tests whether the platform Game Mode API is available, which requires Android 12 + * (API 31) or newer and a device that publishes the game service. + * + * @return true if the game mode can be read and reported, 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 the API is + * unavailable or the platform does not report a game mode + */ + public GameMode getGameMode() { + if (gameManager == null) { + return GameMode.UNSUPPORTED; + } + try { + 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; + } + } + + /** + * 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. + * + *This is a no-op that returns false before Android 13 (API 33) and on devices + * without the Game Mode API.
+ * + * @param gameState the state built with a {@code GameState.Builder} + * @return true if the state was reported to the platform, false otherwise + */ + public boolean setGameState(GameState gameState) { + if (gameManager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return false; + } + try { + gameManager.setGameState(gameState); + return true; + } catch (Throwable throwable) { + logger.log(Level.WARNING, "Unable to report the Android game state", throwable); + return false; + } + } + + /** + * 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() + */ + public void setListener(OnGameModeChanged listener) { + this.listener = listener; + if (listener != null) { + refresh(); + } + } + + /** + * 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() + */ + public void refresh() { + OnGameModeChanged target = listener; + if (target != null) { + target.onGameModeChanged(getGameMode()); + } + } +} 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..10cb6c8e00 --- /dev/null +++ b/jme3-android/src/main/java/com/jme3/system/android/GameMode.java @@ -0,0 +1,121 @@ +/* + * 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), + + /** + * 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; + + 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 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()) { + 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..9a8a2652e8 --- /dev/null +++ b/jme3-android/src/main/java/com/jme3/system/android/OnGameModeChanged.java @@ -0,0 +1,67 @@ +/* + * 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; + +/** + * jME listener that receives the game mode the Android platform currently reports. + * + *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)}, + * which refresh it when they are resumed.
+ * + *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.
+ * + *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 + */ +public interface OnGameModeChanged { + + /** + * 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 + */ + 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..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 @@ -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); @@ -276,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; } @@ -414,6 +422,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 +662,36 @@ public void setOnLayoutDrawn(OnLayoutDrawn onLayoutDrawn) { this.onLayoutDrawn = onLayoutDrawn; } + /** + * Registers a listener that receives the game mode the Android platform currently + * reports. + * + *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 + * 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; }