[Android] Fix handling of back navigation when targeting API level 36

This commit is contained in:
Fredia Huya-Kouadio
2026-03-19 13:35:54 -07:00
parent 06c3946e35
commit ea070aceec
7 changed files with 118 additions and 13 deletions
@@ -34,8 +34,11 @@ import android.content.ComponentName
import android.content.Intent
import android.util.Log
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.godot.game.test.GodotAppInstrumentedTestPlugin
import org.godotengine.godot.Godot
import org.godotengine.godot.GodotActivity.Companion.EXTRA_COMMAND_LINE_PARAMS
import org.godotengine.godot.plugin.GodotPluginRegistry
import org.junit.Test
@@ -169,4 +172,56 @@ class GodotAppTest {
}
}
}
/**
* Validate that the back press does not quit the game when 'quit_on_go_back' is disabled.
*/
@Test
fun testGameNotQuittingOnBackPress() {
ActivityScenario.launch(GodotApp::class.java).use { scenario ->
val testPlugin = getTestPlugin()
assertNotNull(testPlugin)
Log.d(TAG, "Waiting for the Godot main loop to start...")
testPlugin.waitForGodotMainLoopStarted()
// Disable 'quit_on_go_back'.
testPlugin.updateQuitOnGoBack(false)
// Trigger the back press event.
Espresso.pressBackUnconditionally()
Log.d(TAG, "Waiting for the engine to terminate...")
testPlugin.waitForEngineTermination(5_000L)
val godot = Godot.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
assertTrue { godot.runStatus != Godot.RunStatus.TERMINATING }
}
}
/**
* Validate that the back press event quits the game when 'quit_on_go_back' is enabled.
*/
@Test
fun testGameQuittingOnBackPress() {
ActivityScenario.launch(GodotApp::class.java).use { scenario ->
val testPlugin = getTestPlugin()
assertNotNull(testPlugin)
Log.d(TAG, "Waiting for the Godot main loop to start...")
testPlugin.waitForGodotMainLoopStarted()
// Enable 'quit_on_go_back'.
testPlugin.updateQuitOnGoBack(true)
// Trigger the back press event.
Espresso.pressBackUnconditionally()
Log.d(TAG, "Waiting for the engine to terminate...")
testPlugin.waitForEngineTermination(5_000L)
val godot = Godot.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
assertTrue { godot.runStatus == Godot.RunStatus.TERMINATING }
}
}
}
@@ -7,6 +7,7 @@ func _ready():
if Engine.has_singleton(_plugin_name):
_android_plugin = Engine.get_singleton(_plugin_name)
_android_plugin.connect("launch_tests", _launch_tests)
_android_plugin.connect("update_quit_on_go_back", _update_quit_on_go_back)
else:
printerr("Couldn't find plugin " + _plugin_name)
get_tree().quit()
@@ -28,6 +29,10 @@ func _launch_tests(test_label: String) -> void:
_android_plugin.onTestsFailed(test_label, "Unable to launch tests")
func _update_quit_on_go_back(quit_on_go_back: bool) -> void:
get_tree().quit_on_go_back = quit_on_go_back
func _on_plugin_toast_button_pressed() -> void:
if _android_plugin:
_android_plugin.helloWorld()
@@ -12,7 +12,7 @@ func run_tests():
# Scoped storage: Testing access to Downloads and Documents directory.
var version = JavaClassWrapper.wrap("android.os.Build$VERSION")
if version.SDK_INT >= 29:
if version.SDK_INT >= 30:
__exec_test(test_downloads_dir_access)
__exec_test(test_documents_dir_access)
@@ -38,6 +38,7 @@ import org.godotengine.godot.plugin.UsedByGodot
import org.godotengine.godot.plugin.SignalInfo
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* [GodotPlugin] used to drive instrumented tests.
@@ -47,14 +48,17 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
companion object {
private val TAG = GodotAppInstrumentedTestPlugin::class.java.simpleName
private const val MAIN_LOOP_STARTED_LATCH_KEY = "main_loop_started_latch"
private const val ENGINE_TERMINATING_LATCH_KEY = "engine_terminating_latch"
private const val JAVACLASSWRAPPER_TESTS = "javaclasswrapper_tests"
private const val FILE_ACCESS_TESTS = "file_access_tests"
private val LAUNCH_TESTS_SIGNAL = SignalInfo("launch_tests", String::class.java)
private val UPDATE_QUIT_ON_GO_BACK_SIGNAL = SignalInfo("update_quit_on_go_back", java.lang.Boolean::class.java)
private val SIGNALS = setOf(
LAUNCH_TESTS_SIGNAL
LAUNCH_TESTS_SIGNAL,
UPDATE_QUIT_ON_GO_BACK_SIGNAL
)
}
@@ -65,6 +69,8 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
// Add a countdown latch that is triggered when `onGodotMainLoopStarted` is fired.
// This will be used by tests to wait until the engine is ready.
latches[MAIN_LOOP_STARTED_LATCH_KEY] = CountDownLatch(1)
// Add a countdown latch that is triggered when the engine terminates.
latches[ENGINE_TERMINATING_LATCH_KEY] = CountDownLatch(1)
}
override fun getPluginName() = "GodotAppInstrumentedTestPlugin"
@@ -76,6 +82,11 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
latches.remove(MAIN_LOOP_STARTED_LATCH_KEY)?.countDown()
}
override fun onGodotTerminating() {
super.onGodotTerminating()
latches.remove(ENGINE_TERMINATING_LATCH_KEY)?.countDown()
}
/**
* Used by the instrumented test to wait until the Godot main loop is up and running.
*/
@@ -88,6 +99,19 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
}
}
internal fun waitForEngineTermination(timeoutInMs: Long) {
// Wait on the CountDownLatch for `onGodotTerminating`.
try {
latches[ENGINE_TERMINATING_LATCH_KEY]?.await(timeoutInMs, TimeUnit.MILLISECONDS)
} catch (e: InterruptedException) {
Log.e(TAG, "Unable to wait for engine termination event.", e)
}
}
internal fun updateQuitOnGoBack(quitOnGoBack: Boolean) {
emitSignal(UPDATE_QUIT_ON_GO_BACK_SIGNAL, quitOnGoBack)
}
/**
* This launches the JavaClassWrapper tests, and wait until the tests are complete before returning.
*/
@@ -104,7 +128,7 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
private fun launchTests(testLabel: String): Result<Any>? {
val latch = latches.getOrPut(testLabel) { CountDownLatch(1) }
emitSignal(LAUNCH_TESTS_SIGNAL.name, testLabel)
emitSignal(LAUNCH_TESTS_SIGNAL, testLabel)
return try {
latch.await()
val result = testResults.remove(testLabel)
@@ -122,6 +122,15 @@ class Godot private constructor(val context: Context) {
internal fun isEditorBuild() = BuildConfig.FLAVOR == EDITOR_FLAVOR
}
/**
* Describes the engine current run status.
*/
enum class RunStatus {
INITIALIZING,
STARTED,
TERMINATING
}
private val mSensorManager: SensorManager? by lazy { context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager }
private val mClipboard: ClipboardManager? by lazy { context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager }
private val vibratorService: Vibrator? by lazy { context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator }
@@ -185,9 +194,11 @@ class Godot private constructor(val context: Context) {
private var resumed = false
/**
* Tracks whether [onGodotSetupCompleted] fired.
* Tracks the engine's run status.
*/
private val godotMainLoopStarted = AtomicBoolean(false)
private val _runStatus = AtomicReference<RunStatus>(RunStatus.INITIALIZING)
val runStatus: RunStatus
get() = _runStatus.get()
val io = GodotIO(this)
@@ -697,7 +708,7 @@ class Godot private constructor(val context: Context) {
}
private fun registerSensorsIfNeeded() {
if (!resumed || !godotMainLoopStarted.get()) {
if (!resumed || runStatus != RunStatus.STARTED) {
return
}
@@ -858,7 +869,7 @@ class Godot private constructor(val context: Context) {
*/
private fun onGodotMainLoopStarted() {
Log.v(TAG, "OnGodotMainLoopStarted")
godotMainLoopStarted.set(true)
_runStatus.set(RunStatus.STARTED)
accelerometerEnabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_accelerometer")))
gravityEnabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_gravity")))
@@ -881,6 +892,11 @@ class Godot private constructor(val context: Context) {
@Keep
private fun onGodotTerminating() {
Log.v(TAG, "OnGodotTerminating")
_runStatus.set(RunStatus.TERMINATING)
for (plugin in pluginRegistry.allPlugins) {
plugin.onGodotTerminating()
}
runOnTerminate.get()?.run()
}
@@ -41,6 +41,7 @@ import android.os.Bundle
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.activity.addCallback
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.fragment.app.FragmentActivity
@@ -54,7 +55,7 @@ import java.util.concurrent.atomic.AtomicReference
/**
* Base abstract activity for Android apps intending to use Godot as the primary screen.
*
* Also a reference implementation for how to setup and use the [GodotFragment] fragment
* Also a reference implementation for how to set up and use the [GodotFragment] fragment
* within an Android app.
*/
abstract class GodotActivity : FragmentActivity(), GodotHost, PictureInPictureProvider {
@@ -133,6 +134,9 @@ abstract class GodotActivity : FragmentActivity(), GodotHost, PictureInPicturePr
setContentView(getGodotAppLayout())
// Register `OnBackPressedCallback` for the Godot fragment.
onBackPressedDispatcher.addCallback { godotFragment?.onBackPressed() }
handleStartIntent(intent, true)
val currentFragment = supportFragmentManager.findFragmentById(R.id.godot_fragment_container)
@@ -283,10 +287,6 @@ abstract class GodotActivity : FragmentActivity(), GodotHost, PictureInPicturePr
}
}
override fun onBackPressed() {
godotFragment?.onBackPressed() ?: super.onBackPressed()
}
override fun getActivity(): Activity? {
return this
}
@@ -235,6 +235,11 @@ public abstract class GodotPlugin {
*/
public void onGodotMainLoopStarted() {}
/**
* Invoked on the render thread when the Godot engine is terminating.
*/
public void onGodotTerminating() {}
/**
* When using the OpenGL renderer, this is invoked once per frame on the GL thread after the
* frame is drawn.
@@ -414,7 +419,7 @@ public abstract class GodotPlugin {
Object signalArg = signalArgs[i];
if (signalArg != null && !signalParamTypes[i].isInstance(signalArg)) {
throw new IllegalArgumentException(
"Invalid type for argument #" + i + ". Should be of type " + signalParamTypes[i].getName());
"Invalid type for argument #" + i + ". Should be of type '" + signalParamTypes[i].getName() + "' but is of type '" + signalArg.getClass().getName() + "'.");
}
}