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"> + + 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/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 { 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..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; }