Browse Source

Make use of activity-alias as the launcher mechanism for the Godot editor and the Godot app template

Fredia Huya-Kouadio 1 month ago
parent
commit
2ed51e00a1

+ 1 - 1
platform/android/export/export_plugin.cpp

@@ -2561,7 +2561,7 @@ Error EditorExportPlatformAndroid::run(const Ref<EditorExportPreset> &p_preset,
 		print_verbose(output);
 		print_verbose(output);
 		if (err || rv != 0 || output.contains("Error: Activity not started")) {
 		if (err || rv != 0 || output.contains("Error: Activity not started")) {
 			// The implicit launch failed, let's try an explicit launch by specifying the component name before giving up.
 			// The implicit launch failed, let's try an explicit launch by specifying the component name before giving up.
-			const String component_name = get_package_name(p_preset, package_name) + "/com.godot.game.GodotApp";
+			const String component_name = get_package_name(p_preset, package_name) + "/com.godot.game.GodotAppLauncher";
 			print_line("Implicit launch failed... Trying explicit launch using", component_name);
 			print_line("Implicit launch failed... Trying explicit launch using", component_name);
 			args.erase(get_package_name(p_preset, package_name));
 			args.erase(get_package_name(p_preset, package_name));
 			args.push_back("-n");
 			args.push_back("-n");

+ 33 - 11
platform/android/export/gradle_export_util.cpp

@@ -31,6 +31,7 @@
 #include "gradle_export_util.h"
 #include "gradle_export_util.h"
 
 
 #include "core/string/translation_server.h"
 #include "core/string/translation_server.h"
+#include "modules/regex/regex.h"
 
 
 int _get_android_orientation_value(DisplayServer::ScreenOrientation screen_orientation) {
 int _get_android_orientation_value(DisplayServer::ScreenOrientation screen_orientation) {
 	switch (screen_orientation) {
 	switch (screen_orientation) {
@@ -284,6 +285,19 @@ String _get_screen_sizes_tag(const Ref<EditorExportPreset> &p_preset) {
 }
 }
 
 
 String _get_activity_tag(const Ref<EditorExportPlatform> &p_export_platform, const Ref<EditorExportPreset> &p_preset, bool p_debug) {
 String _get_activity_tag(const Ref<EditorExportPlatform> &p_export_platform, const Ref<EditorExportPreset> &p_preset, bool p_debug) {
+	String export_plugins_activity_element_contents;
+	Vector<Ref<EditorExportPlugin>> export_plugins = EditorExport::get_singleton()->get_export_plugins();
+	for (int i = 0; i < export_plugins.size(); i++) {
+		if (export_plugins[i]->supports_platform(p_export_platform)) {
+			const String contents = export_plugins[i]->get_android_manifest_activity_element_contents(p_export_platform, p_debug);
+			if (!contents.is_empty()) {
+				export_plugins_activity_element_contents += contents;
+				export_plugins_activity_element_contents += "\n";
+			}
+		}
+	}
+
+	// Update the GodotApp activity tag.
 	String orientation = _get_android_orientation_label(DisplayServer::ScreenOrientation(int(p_export_platform->get_project_setting(p_preset, "display/window/handheld/orientation"))));
 	String orientation = _get_android_orientation_label(DisplayServer::ScreenOrientation(int(p_export_platform->get_project_setting(p_preset, "display/window/handheld/orientation"))));
 	String manifest_activity_text = vformat(
 	String manifest_activity_text = vformat(
 			"        <activity android:name=\".GodotApp\" "
 			"        <activity android:name=\".GodotApp\" "
@@ -296,6 +310,20 @@ String _get_activity_tag(const Ref<EditorExportPlatform> &p_export_platform, con
 			orientation,
 			orientation,
 			bool_to_string(bool(p_export_platform->get_project_setting(p_preset, "display/window/size/resizable"))));
 			bool_to_string(bool(p_export_platform->get_project_setting(p_preset, "display/window/size/resizable"))));
 
 
+	// *LAUNCHER and *HOME categories should only go to the activity-alias.
+	Ref<RegEx> activity_content_to_remove_regex = RegEx::create_from_string(R"delim(<category\s+android:name\s*=\s*"\S+(LAUNCHER|HOME)"\s*\/>)delim");
+	String updated_export_plugins_activity_element_contents = activity_content_to_remove_regex->sub(export_plugins_activity_element_contents, "", true);
+	manifest_activity_text += updated_export_plugins_activity_element_contents;
+
+	manifest_activity_text += "        </activity>\n";
+
+	// Update the GodotAppLauncher activity tag.
+	manifest_activity_text += "        <activity-alias\n"
+							  "            tools:node=\"mergeOnlyAttributes\"\n"
+							  "            android:name=\".GodotAppLauncher\"\n"
+							  "            android:targetActivity=\".GodotApp\"\n"
+							  "            android:exported=\"true\">\n";
+
 	manifest_activity_text += "            <intent-filter>\n"
 	manifest_activity_text += "            <intent-filter>\n"
 							  "                <action android:name=\"android.intent.action.MAIN\" />\n"
 							  "                <action android:name=\"android.intent.action.MAIN\" />\n"
 							  "                <category android:name=\"android.intent.category.DEFAULT\" />\n";
 							  "                <category android:name=\"android.intent.category.DEFAULT\" />\n";
@@ -317,18 +345,12 @@ String _get_activity_tag(const Ref<EditorExportPlatform> &p_export_platform, con
 
 
 	manifest_activity_text += "            </intent-filter>\n";
 	manifest_activity_text += "            </intent-filter>\n";
 
 
-	Vector<Ref<EditorExportPlugin>> export_plugins = EditorExport::get_singleton()->get_export_plugins();
-	for (int i = 0; i < export_plugins.size(); i++) {
-		if (export_plugins[i]->supports_platform(p_export_platform)) {
-			const String contents = export_plugins[i]->get_android_manifest_activity_element_contents(p_export_platform, p_debug);
-			if (!contents.is_empty()) {
-				manifest_activity_text += contents;
-				manifest_activity_text += "\n";
-			}
-		}
-	}
+	// Hybrid categories should only go to the actual 'GodotApp' activity.
+	Ref<RegEx> activity_alias_content_to_remove_regex = RegEx::create_from_string(R"delim(<category\s+android:name\s*=\s*"org.godotengine.xr.hybrid.(IMMERSIVE|PANEL)"\s*\/>)delim");
+	String updated_export_plugins_activity_alias_element_contents = activity_alias_content_to_remove_regex->sub(export_plugins_activity_element_contents, "", true);
+	manifest_activity_text += updated_export_plugins_activity_alias_element_contents;
 
 
-	manifest_activity_text += "        </activity>\n";
+	manifest_activity_text += "        </activity-alias>\n";
 	return manifest_activity_text;
 	return manifest_activity_text;
 }
 }
 
 

+ 14 - 3
platform/android/java/app/build.gradle

@@ -35,9 +35,11 @@ configurations {
 
 
 dependencies {
 dependencies {
     // Android instrumented test dependencies
     // Android instrumented test dependencies
-    androidTestImplementation "androidx.test.ext:junit:1.3.0"
-    androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0"
-    androidTestImplementation "org.jetbrains.kotlin:kotlin-test:1.3.11"
+    androidTestImplementation "androidx.test.ext:junit:$versions.junitVersion"
+    androidTestImplementation "androidx.test.espresso:espresso-core:$versions.espressoCoreVersion"
+    androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$versions.kotlinTestVersion"
+    androidTestImplementation "androidx.test:runner:$versions.testRunnerVersion"
+    androidTestUtil "androidx.test:orchestrator:$versions.testOrchestratorVersion"
 
 
     implementation "androidx.fragment:fragment:$versions.fragmentVersion"
     implementation "androidx.fragment:fragment:$versions.fragmentVersion"
     implementation "androidx.core:core-splashscreen:$versions.splashscreenVersion"
     implementation "androidx.core:core-splashscreen:$versions.splashscreenVersion"
@@ -121,6 +123,15 @@ android {
         missingDimensionStrategy 'products', 'template'
         missingDimensionStrategy 'products', 'template'
 
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+        // The following argument makes the Android Test Orchestrator run its
+        // "pm clear" command after each test invocation. This command ensures
+        // that the app's state is completely cleared between tests.
+        testInstrumentationRunnerArguments clearPackageData: 'true'
+    }
+
+    testOptions {
+        execution 'ANDROIDX_TEST_ORCHESTRATOR'
     }
     }
 
 
     lintOptions {
     lintOptions {

+ 6 - 2
platform/android/java/app/config.gradle

@@ -15,8 +15,12 @@ ext.versions = [
     // Also update 'platform/android/detect.py#get_ndk_version()' when this is updated.
     // Also update 'platform/android/detect.py#get_ndk_version()' when this is updated.
     ndkVersion         : '28.1.13356709',
     ndkVersion         : '28.1.13356709',
     splashscreenVersion: '1.0.1',
     splashscreenVersion: '1.0.1',
-    openxrVendorsVersion: '4.1.1-stable'
-
+    openxrVendorsVersion: '4.1.1-stable',
+    junitVersion       : '1.3.0',
+    espressoCoreVersion: '3.7.0',
+    kotlinTestVersion  : '1.3.11',
+    testRunnerVersion  : '1.7.0',
+    testOrchestratorVersion: '1.6.1',
 ]
 ]
 
 
 ext.getExportPackageName = { ->
 ext.getExportPackageName = { ->

+ 86 - 16
platform/android/java/app/src/androidTestInstrumented/java/com/godot/game/GodotAppTest.kt

@@ -30,15 +30,19 @@
 
 
 package com.godot.game
 package com.godot.game
 
 
+import android.content.ComponentName
+import android.content.Intent
 import android.util.Log
 import android.util.Log
-import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.core.app.ActivityScenario
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.godot.game.test.GodotAppInstrumentedTestPlugin
 import com.godot.game.test.GodotAppInstrumentedTestPlugin
+import org.godotengine.godot.GodotActivity.Companion.EXTRA_COMMAND_LINE_PARAMS
 import org.godotengine.godot.plugin.GodotPluginRegistry
 import org.godotengine.godot.plugin.GodotPluginRegistry
-import org.junit.Rule
 import org.junit.Test
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
+import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 import kotlin.test.assertNotNull
+import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
 
 
 /**
 /**
@@ -49,28 +53,94 @@ class GodotAppTest {
 
 
 	companion object {
 	companion object {
 		private val TAG = GodotAppTest::class.java.simpleName
 		private val TAG = GodotAppTest::class.java.simpleName
-	}
 
 
-	@get:Rule
-	val godotAppRule = ActivityScenarioRule(GodotApp::class.java)
+		private const val GODOT_APP_LAUNCHER_CLASS_NAME = "com.godot.game.GodotAppLauncher"
+		private const val GODOT_APP_CLASS_NAME = "com.godot.game.GodotApp"
+
+		private val TEST_COMMAND_LINE_PARAMS = arrayOf("This is a test")
+	}
 
 
 	/**
 	/**
 	 * Runs the JavaClassWrapper tests via the GodotAppInstrumentedTestPlugin.
 	 * Runs the JavaClassWrapper tests via the GodotAppInstrumentedTestPlugin.
 	 */
 	 */
 	@Test
 	@Test
 	fun runJavaClassWrapperTests() {
 	fun runJavaClassWrapperTests() {
-		val testPlugin = GodotPluginRegistry.getPluginRegistry()
-			.getPlugin("GodotAppInstrumentedTestPlugin") as GodotAppInstrumentedTestPlugin?
-		assertNotNull(testPlugin)
+		ActivityScenario.launch(GodotApp::class.java).use { scenario ->
+			scenario.onActivity { activity ->
+				val testPlugin = GodotPluginRegistry.getPluginRegistry()
+					.getPlugin("GodotAppInstrumentedTestPlugin") as GodotAppInstrumentedTestPlugin?
+				assertNotNull(testPlugin)
+
+				Log.d(TAG, "Waiting for the Godot main loop to start...")
+				testPlugin.waitForGodotMainLoopStarted()
+
+				Log.d(TAG, "Running JavaClassWrapper tests...")
+				val result = testPlugin.runJavaClassWrapperTests()
+				assertNotNull(result)
+				result.exceptionOrNull()?.let { throw it }
+				assertTrue(result.isSuccess)
+				Log.d(TAG, "Passed ${result.getOrNull()} tests")
+			}
+		}
+	}
 
 
-		Log.d(TAG, "Waiting for the Godot main loop to start...")
-		testPlugin.waitForGodotMainLoopStarted()
+	/**
+	 * Test implicit launch of the Godot app, and validates this resolves to the `GodotAppLauncher` activity alias.
+	 */
+	@Test
+	fun testImplicitGodotAppLauncherLaunch() {
+		val implicitLaunchIntent = Intent().apply {
+			setPackage(BuildConfig.APPLICATION_ID)
+			action = Intent.ACTION_MAIN
+			addCategory(Intent.CATEGORY_LAUNCHER)
+			putExtra(EXTRA_COMMAND_LINE_PARAMS, TEST_COMMAND_LINE_PARAMS)
+		}
+		ActivityScenario.launch<GodotApp>(implicitLaunchIntent).use { scenario ->
+			scenario.onActivity { activity ->
+				assertEquals(activity.intent.component?.className, GODOT_APP_LAUNCHER_CLASS_NAME)
+
+				val commandLineParams = activity.intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+				assertNull(commandLineParams)
+			}
+		}
+	}
+
+	/**
+	 * Test explicit launch of the Godot app via its activity-alias launcher, and validates it resolves properly.
+	 */
+	@Test
+	fun testExplicitGodotAppLauncherLaunch() {
+		val explicitIntent = Intent().apply {
+			component = ComponentName(BuildConfig.APPLICATION_ID, GODOT_APP_LAUNCHER_CLASS_NAME)
+			putExtra(EXTRA_COMMAND_LINE_PARAMS, TEST_COMMAND_LINE_PARAMS)
+		}
+		ActivityScenario.launch<GodotApp>(explicitIntent).use { scenario ->
+			scenario.onActivity { activity ->
+				assertEquals(activity.intent.component?.className, GODOT_APP_LAUNCHER_CLASS_NAME)
+
+				val commandLineParams = activity.intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+				assertNull(commandLineParams)
+			}
+		}
+	}
+
+	/**
+	 * Test explicit launch of the `GodotApp` activity.
+	 */
+	@Test
+	fun testExplicitGodotAppLaunch() {
+		val explicitIntent = Intent().apply {
+			component = ComponentName(BuildConfig.APPLICATION_ID, GODOT_APP_CLASS_NAME)
+			putExtra(EXTRA_COMMAND_LINE_PARAMS, TEST_COMMAND_LINE_PARAMS)
+		}
+		ActivityScenario.launch<GodotApp>(explicitIntent).use { scenario ->
+			scenario.onActivity { activity ->
+				assertEquals(activity.intent.component?.className, GODOT_APP_CLASS_NAME)
 
 
-		Log.d(TAG, "Running JavaClassWrapper tests...")
-		val result = testPlugin.runJavaClassWrapperTests()
-		assertNotNull(result)
-		result.exceptionOrNull()?.let { throw it }
-		assertTrue(result.isSuccess)
-		Log.d(TAG, "Passed ${result.getOrNull()} tests")
+				val commandLineParams = activity.intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+				assertNotNull(commandLineParams)
+				assertTrue(commandLineParams.contentEquals(TEST_COMMAND_LINE_PARAMS))
+			}
+		}
 	}
 	}
 }
 }

+ 7 - 5
platform/android/java/app/src/main/AndroidManifest.xml

@@ -31,23 +31,25 @@
 
 
         <activity
         <activity
             android:name=".GodotApp"
             android:name=".GodotApp"
-            android:label="@string/godot_project_name_string"
             android:theme="@style/GodotAppSplashTheme"
             android:theme="@style/GodotAppSplashTheme"
             android:launchMode="singleInstancePerTask"
             android:launchMode="singleInstancePerTask"
             android:excludeFromRecents="false"
             android:excludeFromRecents="false"
-            android:exported="true"
+            android:exported="false"
             android:screenOrientation="landscape"
             android:screenOrientation="landscape"
             android:windowSoftInputMode="adjustResize"
             android:windowSoftInputMode="adjustResize"
             android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
             android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
             android:resizeableActivity="false"
             android:resizeableActivity="false"
-            tools:ignore="UnusedAttribute" >
-
+            tools:ignore="UnusedAttribute" />
+        <activity-alias
+            android:name=".GodotAppLauncher"
+            android:targetActivity=".GodotApp"
+            android:exported="true">
             <intent-filter>
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.DEFAULT" />
                 <category android:name="android.intent.category.DEFAULT" />
                 <category android:name="android.intent.category.LAUNCHER" />
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
             </intent-filter>
-        </activity>
+        </activity-alias>
 
 
     </application>
     </application>
 
 

+ 18 - 0
platform/android/java/editor/build.gradle

@@ -91,6 +91,17 @@ android {
         ]
         ]
 
 
         ndk { debugSymbolLevel 'NONE' }
         ndk { debugSymbolLevel 'NONE' }
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+        // The following argument makes the Android Test Orchestrator run its
+        // "pm clear" command after each test invocation. This command ensures
+        // that the app's state is completely cleared between tests.
+        testInstrumentationRunnerArguments clearPackageData: 'true'
+    }
+
+    testOptions {
+        execution 'ANDROIDX_TEST_ORCHESTRATOR'
     }
     }
 
 
     base {
     base {
@@ -196,4 +207,11 @@ dependencies {
     horizonosImplementation "org.godotengine:godot-openxr-vendors-meta:$versions.openxrVendorsVersion"
     horizonosImplementation "org.godotengine:godot-openxr-vendors-meta:$versions.openxrVendorsVersion"
     // Pico dependencies
     // Pico dependencies
     picoosImplementation "org.godotengine:godot-openxr-vendors-pico:$versions.openxrVendorsVersion"
     picoosImplementation "org.godotengine:godot-openxr-vendors-pico:$versions.openxrVendorsVersion"
+
+    // Android instrumented test dependencies
+    androidTestImplementation "androidx.test.ext:junit:$versions.junitVersion"
+    androidTestImplementation "androidx.test.espresso:espresso-core:$versions.espressoCoreVersion"
+    androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$versions.kotlinTestVersion"
+    androidTestImplementation "androidx.test:runner:$versions.testRunnerVersion"
+    androidTestUtil "androidx.test:orchestrator:$versions.testOrchestratorVersion"
 }
 }

+ 117 - 0
platform/android/java/editor/src/androidTest/java/org/godotengine/editor/GodotEditorTest.kt

@@ -0,0 +1,117 @@
+/**************************************************************************/
+/*  GodotEditorTest.kt                                                    */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+package org.godotengine.editor
+
+import android.content.ComponentName
+import android.content.Intent
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.godotengine.godot.GodotActivity.Companion.EXTRA_COMMAND_LINE_PARAMS
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Instrumented test for the Godot editor.
+ */
+@RunWith(AndroidJUnit4::class)
+class GodotEditorTest {
+	companion object {
+		private val TAG = GodotEditorTest::class.simpleName
+
+		private val TEST_COMMAND_LINE_PARAMS = arrayOf("This is a test")
+		private const val PROJECT_MANAGER_CLASS_NAME = "org.godotengine.editor.ProjectManager"
+		private const val GODOT_EDITOR_CLASS_NAME = "org.godotengine.editor.GodotEditor"
+	}
+
+	/**
+	 * Implicitly launch the project manager.
+	 */
+	@Test
+	fun testImplicitProjectManagerLaunch() {
+		val implicitLaunchIntent = Intent().apply {
+			setPackage(BuildConfig.APPLICATION_ID)
+			action = Intent.ACTION_MAIN
+			addCategory(Intent.CATEGORY_LAUNCHER)
+			putExtra(EXTRA_COMMAND_LINE_PARAMS, TEST_COMMAND_LINE_PARAMS)
+		}
+		ActivityScenario.launch<GodotEditor>(implicitLaunchIntent).use { scenario ->
+			scenario.onActivity { activity ->
+				assertEquals(activity.intent.component?.className, PROJECT_MANAGER_CLASS_NAME)
+
+				val commandLineParams = activity.intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+				assertNull(commandLineParams)
+			}
+		}
+	}
+
+	/**
+	 * Explicitly launch the project manager.
+	 */
+	@Test
+	fun testExplicitProjectManagerLaunch() {
+		val explicitProjectManagerIntent = Intent().apply {
+			component = ComponentName(BuildConfig.APPLICATION_ID, PROJECT_MANAGER_CLASS_NAME)
+			putExtra(EXTRA_COMMAND_LINE_PARAMS, TEST_COMMAND_LINE_PARAMS)
+		}
+		ActivityScenario.launch<GodotEditor>(explicitProjectManagerIntent).use { scenario ->
+			scenario.onActivity { activity ->
+				assertEquals(activity.intent.component?.className, PROJECT_MANAGER_CLASS_NAME)
+
+				val commandLineParams = activity.intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+				assertNull(commandLineParams)
+			}
+		}
+	}
+
+	/**
+	 * Explicitly launch the `GodotEditor` activity.
+	 */
+	@Test
+	fun testExplicitGodotEditorLaunch() {
+		val godotEditorIntent = Intent().apply {
+			component = ComponentName(BuildConfig.APPLICATION_ID, GODOT_EDITOR_CLASS_NAME)
+			putExtra(EXTRA_COMMAND_LINE_PARAMS, TEST_COMMAND_LINE_PARAMS)
+		}
+		ActivityScenario.launch<GodotEditor>(godotEditorIntent).use { scenario ->
+			scenario.onActivity { activity ->
+				assertEquals(activity.intent.component?.className, GODOT_EDITOR_CLASS_NAME)
+
+				val commandLineParams = activity.intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+				assertNotNull(commandLineParams)
+				assertTrue(commandLineParams.contentEquals(TEST_COMMAND_LINE_PARAMS))
+			}
+		}
+	}
+}

+ 13 - 2
platform/android/java/editor/src/horizonos/AndroidManifest.xml

@@ -47,20 +47,31 @@
 
 
         <activity
         <activity
             android:name=".GodotEditor"
             android:name=".GodotEditor"
-            android:exported="true"
+            android:exported="false"
             android:screenOrientation="landscape"
             android:screenOrientation="landscape"
             tools:node="merge"
             tools:node="merge"
             tools:replace="android:screenOrientation">
             tools:replace="android:screenOrientation">
             <intent-filter>
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.DEFAULT" />
                 <category android:name="android.intent.category.DEFAULT" />
-                <category android:name="android.intent.category.LAUNCHER" />
                 <category android:name="com.oculus.intent.category.2D" />
                 <category android:name="com.oculus.intent.category.2D" />
             </intent-filter>
             </intent-filter>
 
 
             <meta-data android:name="com.oculus.vrshell.free_resizing_lock_aspect_ratio" android:value="true"/>
             <meta-data android:name="com.oculus.vrshell.free_resizing_lock_aspect_ratio" android:value="true"/>
         </activity>
         </activity>
+        <activity-alias
+            android:name=".ProjectManager"
+            android:exported="true"
+            tools:node="merge"
+            android:targetActivity=".GodotEditor">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
 
 
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="com.oculus.intent.category.2D" />
+            </intent-filter>
+        </activity-alias>
         <activity
         <activity
             android:name=".GodotXRGame"
             android:name=".GodotXRGame"
             android:exported="false"
             android:exported="false"

+ 13 - 8
platform/android/java/editor/src/main/AndroidManifest.xml

@@ -46,7 +46,7 @@
         <activity
         <activity
             android:name=".GodotEditor"
             android:name=".GodotEditor"
             android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
             android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
-            android:exported="true"
+            android:exported="false"
             android:icon="@mipmap/themed_icon"
             android:icon="@mipmap/themed_icon"
             android:launchMode="singleTask"
             android:launchMode="singleTask"
             android:screenOrientation="userLandscape">
             android:screenOrientation="userLandscape">
@@ -54,13 +54,6 @@
                 android:defaultWidth="@dimen/editor_default_window_width"
                 android:defaultWidth="@dimen/editor_default_window_width"
                 android:defaultHeight="@dimen/editor_default_window_height" />
                 android:defaultHeight="@dimen/editor_default_window_height" />
 
 
-            <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-
-                <category android:name="android.intent.category.DEFAULT" />
-                <category android:name="android.intent.category.LAUNCHER" />
-            </intent-filter>
-
             <!-- Intent filter used to intercept hybrid PANEL launch for the current editor project, and route it
             <!-- Intent filter used to intercept hybrid PANEL launch for the current editor project, and route it
             properly through the editor 'run' logic (e.g: debugger setup) -->
             properly through the editor 'run' logic (e.g: debugger setup) -->
             <intent-filter>
             <intent-filter>
@@ -77,6 +70,18 @@
                 <category android:name="org.godotengine.xr.hybrid.IMMERSIVE" />
                 <category android:name="org.godotengine.xr.hybrid.IMMERSIVE" />
             </intent-filter>
             </intent-filter>
         </activity>
         </activity>
+        <activity-alias
+            android:name=".ProjectManager"
+            android:exported="true"
+            android:icon="@mipmap/themed_icon"
+            android:targetActivity=".GodotEditor">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity-alias>
         <activity
         <activity
             android:name=".GodotGame"
             android:name=".GodotGame"
             android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
             android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"

+ 34 - 5
platform/android/java/editor/src/main/java/org/godotengine/editor/BaseGodotEditor.kt

@@ -58,6 +58,7 @@ import org.godotengine.editor.embed.GameMenuFragment
 import org.godotengine.editor.utils.signApk
 import org.godotengine.editor.utils.signApk
 import org.godotengine.editor.utils.verifyApk
 import org.godotengine.editor.utils.verifyApk
 import org.godotengine.godot.BuildConfig
 import org.godotengine.godot.BuildConfig
+import org.godotengine.godot.Godot
 import org.godotengine.godot.GodotActivity
 import org.godotengine.godot.GodotActivity
 import org.godotengine.godot.GodotLib
 import org.godotengine.godot.GodotLib
 import org.godotengine.godot.editor.utils.EditorUtils
 import org.godotengine.godot.editor.utils.EditorUtils
@@ -158,6 +159,20 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
 		internal const val SNACKBAR_SHOW_DURATION_MS = 5000L
 		internal const val SNACKBAR_SHOW_DURATION_MS = 5000L
 
 
 		private const val PREF_KEY_DONT_SHOW_GAME_RESUME_HINT = "pref_key_dont_show_game_resume_hint"
 		private const val PREF_KEY_DONT_SHOW_GAME_RESUME_HINT = "pref_key_dont_show_game_resume_hint"
+
+		@JvmStatic
+		fun isRunningInInstrumentation(): Boolean {
+			if (BuildConfig.BUILD_TYPE == "release") {
+				return false
+			}
+
+			return try {
+				Class.forName("org.godotengine.editor.GodotEditorTest")
+				true
+			} catch (_: ClassNotFoundException) {
+				false
+			}
+		}
 	}
 	}
 
 
 	internal val editorMessageDispatcher = EditorMessageDispatcher(this)
 	internal val editorMessageDispatcher = EditorMessageDispatcher(this)
@@ -229,9 +244,15 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
 			enableEdgeToEdge()
 			enableEdgeToEdge()
 		}
 		}
 
 
-		// We exclude certain permissions from the set we request at startup, as they'll be
-		// requested on demand based on use cases.
-		PermissionsUtil.requestManifestPermissions(this, getExcludedPermissions())
+		// Skip permissions request if running in a device farm (e.g. firebase test lab) or if requested via the launch
+		// intent (e.g. instrumentation tests).
+		val skipPermissionsRequest = isRunningInInstrumentation() ||
+			Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && ActivityManager.isRunningInUserTestHarness()
+		if (!skipPermissionsRequest) {
+			// We exclude certain permissions from the set we request at startup, as they'll be
+			// requested on demand based on use cases.
+			PermissionsUtil.requestManifestPermissions(this, getExcludedPermissions())
+		}
 
 
 		editorMessageDispatcher.parseStartIntent(packageManager, intent)
 		editorMessageDispatcher.parseStartIntent(packageManager, intent)
 
 
@@ -247,7 +268,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
 
 
 	override fun onNewIntent(newIntent: Intent) {
 	override fun onNewIntent(newIntent: Intent) {
 		if (newIntent.hasCategory(HYBRID_APP_PANEL_CATEGORY) || newIntent.hasCategory(HYBRID_APP_IMMERSIVE_CATEGORY)) {
 		if (newIntent.hasCategory(HYBRID_APP_PANEL_CATEGORY) || newIntent.hasCategory(HYBRID_APP_IMMERSIVE_CATEGORY)) {
-			val params = newIntent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+			val params = retrieveCommandLineParamsFromLaunchIntent(newIntent)
 			Log.d(TAG, "Received hybrid transition intent $newIntent with parameters ${params.contentToString()}")
 			Log.d(TAG, "Received hybrid transition intent $newIntent with parameters ${params.contentToString()}")
 			// Override EXTRA_NEW_LAUNCH so the editor is not restarted
 			// Override EXTRA_NEW_LAUNCH so the editor is not restarted
 			newIntent.putExtra(EXTRA_NEW_LAUNCH, false)
 			newIntent.putExtra(EXTRA_NEW_LAUNCH, false)
@@ -257,7 +278,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
 				var scene = ""
 				var scene = ""
 				var xrMode = XR_MODE_DEFAULT
 				var xrMode = XR_MODE_DEFAULT
 				var path = ""
 				var path = ""
-				if (params != null) {
+				if (params.isNotEmpty()) {
 					val sceneIndex = params.indexOf(SCENE_ARG)
 					val sceneIndex = params.indexOf(SCENE_ARG)
 					if (sceneIndex != -1 && sceneIndex + 1 < params.size) {
 					if (sceneIndex != -1 && sceneIndex + 1 < params.size) {
 						scene = params[sceneIndex +1]
 						scene = params[sceneIndex +1]
@@ -511,6 +532,14 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
 		return editorWindowInfo.windowId
 		return editorWindowInfo.windowId
 	}
 	}
 
 
+	override fun onGodotForceQuit(instance: Godot) {
+		if (!isRunningInInstrumentation()) {
+			// For instrumented tests, we disable force-quitting to allow the tests to complete successfully, otherwise
+			// they fail when the process crashes.
+			super.onGodotForceQuit(instance)
+		}
+	}
+
 	final override fun onGodotForceQuit(godotInstanceId: Int): Boolean {
 	final override fun onGodotForceQuit(godotInstanceId: Int): Boolean {
 		val editorWindowInfo = getEditorWindowInfoForInstanceId(godotInstanceId) ?: return super.onGodotForceQuit(godotInstanceId)
 		val editorWindowInfo = getEditorWindowInfoForInstanceId(godotInstanceId) ?: return super.onGodotForceQuit(godotInstanceId)
 
 

+ 11 - 13
platform/android/java/editor/src/main/java/org/godotengine/editor/BaseGodotGame.kt

@@ -63,20 +63,18 @@ abstract class BaseGodotGame: GodotEditor() {
 
 
 		// Check if we should be running in XR instead (if available) as it's possible we were
 		// Check if we should be running in XR instead (if available) as it's possible we were
 		// launched from the project manager which doesn't have that information.
 		// launched from the project manager which doesn't have that information.
-		val launchingArgs = intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
-		if (launchingArgs != null) {
-			val editorWindowInfo = retrieveEditorWindowInfo(launchingArgs, getEditorGameEmbedMode())
-			if (editorWindowInfo != getEditorWindowInfo()) {
-				val relaunchIntent = getNewGodotInstanceIntent(editorWindowInfo, launchingArgs)
-				relaunchIntent.putExtra(EXTRA_NEW_LAUNCH, true)
-					.putExtra(EditorMessageDispatcher.EXTRA_MSG_DISPATCHER_PAYLOAD, intent.getBundleExtra(EditorMessageDispatcher.EXTRA_MSG_DISPATCHER_PAYLOAD))
-
-				Log.d(TAG, "Relaunching XR project using ${editorWindowInfo.windowClassName} with parameters ${launchingArgs.contentToString()}")
-				Godot.getInstance(applicationContext).destroyAndKillProcess {
-					ProcessPhoenix.triggerRebirth(this, relaunchIntent)
-				}
-				return
+		val launchingArgs = retrieveCommandLineParamsFromLaunchIntent()
+		val editorWindowInfo = retrieveEditorWindowInfo(launchingArgs, getEditorGameEmbedMode())
+		if (editorWindowInfo != getEditorWindowInfo()) {
+			val relaunchIntent = getNewGodotInstanceIntent(editorWindowInfo, launchingArgs)
+			relaunchIntent.putExtra(EXTRA_NEW_LAUNCH, true)
+				.putExtra(EditorMessageDispatcher.EXTRA_MSG_DISPATCHER_PAYLOAD, intent.getBundleExtra(EditorMessageDispatcher.EXTRA_MSG_DISPATCHER_PAYLOAD))
+
+			Log.d(TAG, "Relaunching XR project using ${editorWindowInfo.windowClassName} with parameters ${launchingArgs.contentToString()}")
+			Godot.getInstance(applicationContext).destroyAndKillProcess {
+				ProcessPhoenix.triggerRebirth(this, relaunchIntent)
 			}
 			}
+			return
 		}
 		}
 
 
 		// Request project runtime permissions if necessary.
 		// Request project runtime permissions if necessary.

+ 2 - 8
platform/android/java/editor/src/picoos/AndroidManifest.xml

@@ -16,16 +16,10 @@
 
 
         <activity
         <activity
             android:name=".GodotEditor"
             android:name=".GodotEditor"
-            android:exported="true"
+            android:exported="false"
             android:screenOrientation="landscape"
             android:screenOrientation="landscape"
             tools:node="merge"
             tools:node="merge"
-            tools:replace="android:screenOrientation">
-            <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-                <category android:name="android.intent.category.DEFAULT" />
-                <category android:name="android.intent.category.LAUNCHER" />
-            </intent-filter>
-        </activity>
+            tools:replace="android:screenOrientation"/>
 
 
         <activity
         <activity
             android:name=".GodotXRGame"
             android:name=".GodotXRGame"

+ 37 - 8
platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt

@@ -72,18 +72,48 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
 	protected var godotFragment: GodotFragment? = null
 	protected var godotFragment: GodotFragment? = null
 		private set
 		private set
 
 
+	/**
+	 * Strip out the command line parameters from intent targeting exported activities.
+	 */
+	protected fun sanitizeLaunchIntent(launchIntent: Intent = intent): Intent {
+		val targetComponent = launchIntent.component ?: componentName
+		val activityInfo = packageManager.getActivityInfo(targetComponent, 0)
+		if (activityInfo.exported) {
+			launchIntent.removeExtra(EXTRA_COMMAND_LINE_PARAMS)
+		}
+
+		return launchIntent
+	}
+
+	/**
+	 * Only retrieve the command line parameters from the intent from non-exported activities.
+	 * This ensures only internal components can configure how the engine is run.
+	 */
+	protected fun retrieveCommandLineParamsFromLaunchIntent(launchIntent: Intent = intent): Array<String> {
+		val targetComponent = launchIntent.component ?: componentName
+		val activityInfo = packageManager.getActivityInfo(targetComponent, 0)
+		if (!activityInfo.exported) {
+			val params = launchIntent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
+			return params ?: emptyArray()
+		}
+		return emptyArray()
+	}
+
 	@CallSuper
 	@CallSuper
 	override fun onCreate(savedInstanceState: Bundle?) {
 	override fun onCreate(savedInstanceState: Bundle?) {
+		intent = sanitizeLaunchIntent(intent)
+
 		val assetsCommandLine = try {
 		val assetsCommandLine = try {
 			CommandLineFileParser.parseCommandLine(assets.open("_cl_"))
 			CommandLineFileParser.parseCommandLine(assets.open("_cl_"))
-		} catch (ignored: Exception) {
+		} catch (_: Exception) {
 			mutableListOf()
 			mutableListOf()
 		}
 		}
+		Log.d(TAG, "Project command line parameters: $assetsCommandLine")
 		commandLineParams.addAll(assetsCommandLine)
 		commandLineParams.addAll(assetsCommandLine)
 
 
-		val params = intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
-		Log.d(TAG, "Starting intent $intent with parameters ${params.contentToString()}")
-		commandLineParams.addAll(params ?: emptyArray())
+		val intentCommandLine = retrieveCommandLineParamsFromLaunchIntent()
+		Log.d(TAG, "Launch intent $intent with parameters ${intentCommandLine.contentToString()}")
+		commandLineParams.addAll(intentCommandLine)
 
 
 		super.onCreate(savedInstanceState)
 		super.onCreate(savedInstanceState)
 
 
@@ -167,10 +197,9 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
 	}
 	}
 
 
 	override fun onNewIntent(newIntent: Intent) {
 	override fun onNewIntent(newIntent: Intent) {
-		super.onNewIntent(newIntent)
-		intent = newIntent
-
-		handleStartIntent(newIntent, false)
+		intent = sanitizeLaunchIntent(newIntent)
+		super.onNewIntent(intent)
+		handleStartIntent(intent, false)
 	}
 	}
 
 
 	private fun handleStartIntent(intent: Intent, newLaunch: Boolean) {
 	private fun handleStartIntent(intent: Intent, newLaunch: Boolean) {