diff --git a/platform/android/java/app/src/androidTestInstrumented/java/com/godot/game/GodotAppTest.kt b/platform/android/java/app/src/androidTestInstrumented/java/com/godot/game/GodotAppTest.kt index 123c9bd8d4..a0333deccf 100644 --- a/platform/android/java/app/src/androidTestInstrumented/java/com/godot/game/GodotAppTest.kt +++ b/platform/android/java/app/src/androidTestInstrumented/java/com/godot/game/GodotAppTest.kt @@ -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 } + } + } } diff --git a/platform/android/java/app/src/instrumented/assets/main.gd b/platform/android/java/app/src/instrumented/assets/main.gd index 6ce3776745..54d8106edd 100644 --- a/platform/android/java/app/src/instrumented/assets/main.gd +++ b/platform/android/java/app/src/instrumented/assets/main.gd @@ -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() diff --git a/platform/android/java/app/src/instrumented/assets/test/file_access/file_access_tests.gd b/platform/android/java/app/src/instrumented/assets/test/file_access/file_access_tests.gd index 943fe4f384..465b342b6d 100644 --- a/platform/android/java/app/src/instrumented/assets/test/file_access/file_access_tests.gd +++ b/platform/android/java/app/src/instrumented/assets/test/file_access/file_access_tests.gd @@ -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) diff --git a/platform/android/java/app/src/instrumented/java/com/godot/game/test/GodotAppInstrumentedTestPlugin.kt b/platform/android/java/app/src/instrumented/java/com/godot/game/test/GodotAppInstrumentedTestPlugin.kt index d29822b39f..c5c243e159 100644 --- a/platform/android/java/app/src/instrumented/java/com/godot/game/test/GodotAppInstrumentedTestPlugin.kt +++ b/platform/android/java/app/src/instrumented/java/com/godot/game/test/GodotAppInstrumentedTestPlugin.kt @@ -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? { 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) diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt b/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt index 2940701d11..1057994d33 100644 --- a/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt @@ -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.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() } diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt b/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt index 938bdb00b6..6eec5f7887 100644 --- a/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt @@ -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 } diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/plugin/GodotPlugin.java b/platform/android/java/lib/src/main/java/org/godotengine/godot/plugin/GodotPlugin.java index 155badb37f..5c70f9574b 100644 --- a/platform/android/java/lib/src/main/java/org/godotengine/godot/plugin/GodotPlugin.java +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/plugin/GodotPlugin.java @@ -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() + "'."); } }