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..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..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..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; }