Merged upstream 4.7 into downstream 4.6
🔗 GHA / 📊 Static checks (push) Has been cancelled
🔗 GHA / 🤖 Android (push) Has been cancelled
🔗 GHA / 🍏 iOS (push) Has been cancelled
🔗 GHA / 🐧 Linux (push) Has been cancelled
🔗 GHA / 🍎 macOS (push) Has been cancelled
🔗 GHA / 🏁 Windows (push) Has been cancelled
🔗 GHA / 🌐 Web (push) Has been cancelled

This commit is contained in:
2026-06-27 13:51:14 -04:00
5981 changed files with 851233 additions and 341989 deletions
+8 -7
View File
@@ -131,14 +131,7 @@ android {
}
buildTypes {
dev {
initWith debug
applicationIdSuffix ".dev"
manifestPlaceholders += [editorBuildSuffix: " (dev)"]
}
debug {
initWith release
applicationIdSuffix ".debug"
manifestPlaceholders += [editorBuildSuffix: " (debug)"]
signingConfig signingConfigs.debug
@@ -147,6 +140,14 @@ android {
release {
if (hasReleaseSigningConfigs()) {
signingConfig signingConfigs.release
} else {
// We default to the debug signingConfigs when the release signing configs are not
// available (e.g: development in Android Studio).
signingConfig signingConfigs.debug
// In addition, we update the application ID to allow installing an Android studio release build
// side by side with a production build from the store.
applicationIdSuffix ".release"
manifestPlaceholders += [editorBuildSuffix: " (release)"]
}
}
}
@@ -16,6 +16,14 @@
<uses-feature
android:glEsVersion="0x00030000"
android:required="true" />
<uses-feature
android:name="android.hardware.vulkan.version"
android:required="false"
android:version="0x401000" />
<uses-feature
android:name="android.hardware.vulkan.level"
android:required="false"
android:version="1" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@@ -52,7 +60,7 @@
android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
android:exported="false"
android:icon="@mipmap/themed_icon"
android:launchMode="singleTask"
android:launchMode="singleInstancePerTask"
android:screenOrientation="userLandscape">
<layout
android:defaultWidth="@dimen/editor_default_window_width"
@@ -85,6 +93,15 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:host="*" />
<data android:pathPattern=".*\\.godot" />
</intent-filter>
</activity-alias>
<activity
android:name=".GodotGame"
@@ -94,6 +111,7 @@
android:label="@string/godot_game_activity_name"
android:launchMode="singleTask"
android:process=":GodotGame"
android:taskAffinity=":game"
android:autoRemoveFromRecents="true"
android:theme="@style/GodotGameTheme"
android:supportsPictureInPicture="true"
@@ -34,9 +34,12 @@ import android.Manifest
import android.app.ActivityManager
import android.app.ActivityOptions
import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.os.Debug
@@ -71,7 +74,9 @@ import org.godotengine.godot.utils.DialogUtils
import org.godotengine.godot.utils.PermissionsUtil
import org.godotengine.godot.utils.ProcessPhoenix
import org.godotengine.openxr.vendors.utils.*
import java.io.File
import kotlin.math.min
import kotlin.text.indexOf
/**
* Base class for the Godot Android Editor activities.
@@ -152,6 +157,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
internal const val GAME_MENU_ACTION_SET_TIME_SCALE = "setTimeScale"
private const val GAME_WORKSPACE = "Game"
private const val SCRIPT_WORKSPACE = "Script"
internal const val SNACKBAR_SHOW_DURATION_MS = 5000L
@@ -198,6 +204,12 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
protected var gameMenuFragment: GameMenuFragment? = null
protected val gameMenuState = Bundle()
private val updatedCommandLineParams = ArrayList<String>()
private var changingOrientationAllowed = false
private var distractionFreeModeEnabled = false
private var activeWorkspace: String? = null
override fun getGodotAppLayout() = R.layout.godot_editor_layout
internal open fun getEditorWindowInfo() = EDITOR_MAIN_INFO
@@ -254,7 +266,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
editorMessageDispatcher.parseStartIntent(packageManager, intent)
if (BuildConfig.BUILD_TYPE == "dev" && WAIT_FOR_DEBUGGER) {
if (BuildConfig.BUILD_TYPE == "debug" && WAIT_FOR_DEBUGGER) {
Debug.waitForDebugger()
}
@@ -264,6 +276,14 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
setupGameMenuBar()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Show EditorTitleBar on small screens only in landscape due to width limitations in portrait.
// TODO: Enable for portrait once the title bar width is optimized.
EditorUtils.toggleTitleBar(isLargeScreen || newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE)
}
override fun onDestroy() {
gradleBuildProvider.buildEnvDisconnect()
super.onDestroy()
@@ -319,6 +339,91 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
super.onNewIntent(newIntent)
}
override fun handleStartIntent(intent: Intent, newLaunch: Boolean) {
when (intent.action) {
Intent.ACTION_VIEW -> {
val rootDir = Environment.getExternalStorageDirectory().canonicalPath
val dataPath = when (intent.scheme) {
ContentResolver.SCHEME_FILE -> {
intent.data?.path
}
ContentResolver.SCHEME_CONTENT -> {
// This approach is not recommend with 'content' scheme, but we require the filesystem path in
// order to open its parent directory and load the project.
val uriPath = intent.data?.path
if (uriPath != null) {
// Try and see if the external storage directory is part of the uri path.
val rootDirIndex = uriPath.indexOf(rootDir)
if (rootDirIndex != -1) {
uriPath.substring(rootDirIndex)
} else {
// Try and see if we can retrieve an existing relative path.
val pathParts = uriPath.split(':', '/')
var currentPath = ""
for (index in pathParts.size -1 downTo 0) {
currentPath = if (currentPath == "") {
pathParts[index]
} else {
"${pathParts[index]}/$currentPath"
}
val currentFile = File(rootDir, currentPath)
if (currentFile.exists()) {
break
}
}
currentPath
}
} else {
null
}
}
else -> null
}
if (!dataPath.isNullOrBlank()) {
var dataFile = File(dataPath)
if (!dataFile.isAbsolute) {
dataFile = File(rootDir, dataPath)
}
val dataDir = dataFile.parentFile
if (dataDir?.isDirectory == true) {
val loadProjectArgs = arrayOf(EDITOR_ARG, PATH_ARG, dataDir.absolutePath)
if (newLaunch) {
// Update the command line parameters to load the specified project.
updatedCommandLineParams.addAll(loadProjectArgs)
} else {
// Check if we are already editing the specified directory.
var isEditor = false
var nextIsPath = false
var currentPath = ""
for (arg in commandLine) {
if (nextIsPath) {
currentPath = arg
nextIsPath = false
}
if (arg == EDITOR_ARG || arg == EDITOR_ARG_SHORT) {
isEditor = true
} else if (arg == PATH_ARG) {
nextIsPath = true
}
}
if (!isEditor || currentPath != dataDir.absolutePath) {
onNewGodotInstanceRequested(loadProjectArgs)
}
}
}
}
}
}
super.handleStartIntent(intent, newLaunch)
}
protected open fun shouldShowGameMenuBar() = gameMenuContainer != null
private fun setupGameMenuBar() {
@@ -345,6 +450,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
val longPressEnabled = enableLongPressGestures()
val panScaleEnabled = enablePanAndScaleGestures()
val overrideVolumeButtonsEnabled = overrideVolumeButtons()
val hapticEnabled = enableHapticOnLongPress()
runOnUiThread {
// Enable long press, panning and scaling gestures
@@ -352,6 +458,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
enableLongPress(longPressEnabled)
enablePanningAndScalingGestures(panScaleEnabled)
setOverrideVolumeButtons(overrideVolumeButtonsEnabled)
enableHapticFeedback(hapticEnabled)
}
}
}
@@ -404,7 +511,10 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
override fun getCommandLine(): MutableList<String> {
val params = super.getCommandLine()
if (BuildConfig.BUILD_TYPE == "dev" && !params.contains("--benchmark")) {
if (updatedCommandLineParams.isNotEmpty()) {
params.addAll(updatedCommandLineParams)
}
if (BuildConfig.BUILD_TYPE == "debug" && !params.contains("--benchmark")) {
params.add("--benchmark")
}
return params
@@ -603,7 +713,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
/**
* The Godot Android Editor sets its own orientation via its AndroidManifest
*/
protected open fun overrideOrientationRequest() = true
protected open fun overrideOrientationRequest() = !changingOrientationAllowed
protected open fun overrideVolumeButtons() = false
@@ -613,6 +723,12 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
protected open fun enableLongPressGestures() =
java.lang.Boolean.parseBoolean(GodotLib.getEditorSetting("interface/touchscreen/enable_long_press_as_right_click"))
/**
* Enable haptic feedback on long-press right-click for the Godot Android editor.
*/
protected open fun enableHapticOnLongPress() =
java.lang.Boolean.parseBoolean(GodotLib.getEditorSetting("interface/touchscreen/haptic_on_long_press"))
/**
* Disable scroll deadzone for the Godot Android editor.
*/
@@ -801,6 +917,8 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
}
override fun onEditorWorkspaceSelected(workspace: String) {
activeWorkspace = workspace
if (workspace == GAME_WORKSPACE && shouldShowGameMenuBar()) {
if (editorMessageDispatcher.bringEditorWindowToFront(EMBEDDED_RUN_GAME_INFO) || editorMessageDispatcher.bringEditorWindowToFront(RUN_GAME_INFO)) {
return
@@ -813,6 +931,23 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
embeddedGameViewContainerWindow?.isVisible = true
}
}
toggleScriptEditorOrientation()
}
override fun onDistractionFreeModeChanged(enabled: Boolean) {
distractionFreeModeEnabled = enabled
toggleScriptEditorOrientation()
}
private fun toggleScriptEditorOrientation() {
if (activeWorkspace == SCRIPT_WORKSPACE && distractionFreeModeEnabled) {
changingOrientationAllowed = true
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
} else if (changingOrientationAllowed) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
changingOrientationAllowed = false
}
}
internal open fun bringSelfToFront() {
@@ -30,12 +30,7 @@
package org.godotengine.editor
import android.app.PictureInPictureParams
import android.content.pm.PackageManager
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.annotation.CallSuper
import androidx.core.view.isVisible
@@ -55,7 +50,6 @@ open class GodotGame : BaseGodotGame() {
private val TAG = GodotGame::class.java.simpleName
}
private val gameViewSourceRectHint = Rect()
private val expandGameMenuButton: View? by lazy { findViewById(R.id.game_menu_expand_button) }
override fun onCreate(savedInstanceState: Bundle?) {
@@ -75,13 +69,6 @@ open class GodotGame : BaseGodotGame() {
gameMenuFragment?.expandGameMenu()
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val gameView = findViewById<View>(R.id.godot_fragment_container)
gameView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
gameView.getGlobalVisibleRect(gameViewSourceRectHint)
}
}
}
override fun getCommandLine(): MutableList<String> {
@@ -96,27 +83,7 @@ open class GodotGame : BaseGodotGame() {
return updatedArgs
}
override fun enterPiPMode() {
if (hasPiPSystemFeature()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val builder = PictureInPictureParams.Builder().setSourceRectHint(gameViewSourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setSeamlessResizeEnabled(false)
}
setPictureInPictureParams(builder.build())
}
Log.v(TAG, "Entering PiP mode")
enterPictureInPictureMode()
}
}
/**
* Returns true the if the device supports picture-in-picture (PiP).
*/
protected fun hasPiPSystemFeature(): Boolean {
return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
override fun isPiPEnabled() = true
override fun shouldShowGameMenuBar(): Boolean {
return intent.getBooleanExtra(
@@ -127,21 +94,11 @@ open class GodotGame : BaseGodotGame() {
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
Log.v(TAG, "onPictureInPictureModeChanged: $isInPictureInPictureMode")
// Hide the game menu fragment when in PiP.
gameMenuContainer?.isVisible = !isInPictureInPictureMode
}
override fun onStop() {
super.onStop()
if (isInPictureInPictureMode && !isFinishing) {
// We get in this state when PiP is closed, so we terminate the activity.
finish()
}
}
override fun getGodotAppLayout() = R.layout.godot_game_layout
override fun getEditorWindowInfo() = RUN_GAME_INFO
@@ -258,7 +215,7 @@ open class GodotGame : BaseGodotGame() {
override fun isCloseButtonEnabled() = !isHorizonOSDevice(applicationContext)
override fun isPiPButtonEnabled() = hasPiPSystemFeature()
override fun isPiPButtonEnabled() = isPiPModeSupported()
override fun isMenuBarCollapsable() = true
@@ -31,13 +31,18 @@
package org.godotengine.editor.embed
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.graphics.Point
import android.os.Bundle
import android.util.Rational
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND
import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
import android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
import android.widget.CheckBox
import org.godotengine.editor.GodotGame
import org.godotengine.editor.R
import org.godotengine.godot.editor.utils.GameMenuUtils
@@ -50,22 +55,67 @@ class EmbeddedGodotGame : GodotGame() {
companion object {
private val TAG = EmbeddedGodotGame::class.java.simpleName
private const val FULL_SCREEN_WIDTH = WindowManager.LayoutParams.MATCH_PARENT
private const val FULL_SCREEN_HEIGHT = WindowManager.LayoutParams.MATCH_PARENT
private const val PREFS_NAME = "embedded_game_window_prefs"
private const val KEY_X = "embedded_window_x"
private const val KEY_Y = "embedded_window_y"
private const val KEY_WIDTH = "embedded_window_width"
private const val KEY_HEIGHT = "embedded_window_height"
private const val KEY_FREE_RESIZE = "is_free_resize"
private const val RESIZE_THRESHOLD = 80f
private const val MIN_WINDOW_SIZE = 400
private const val MAX_SCREEN_PERCENT = 0.9f
private const val RESIZE_UI_HIDE_DELAY_MS = 2000L
}
private val defaultWidthInPx : Int by lazy {
resources.getDimensionPixelSize(R.dimen.embed_game_window_default_width)
}
private val defaultHeightInPx : Int by lazy {
resources.getDimensionPixelSize(R.dimen.embed_game_window_default_height)
}
private val defaultWidthInPx: Int by lazy { resources.getDimensionPixelSize(R.dimen.embed_game_window_default_width) }
private val defaultHeightInPx: Int by lazy { resources.getDimensionPixelSize(R.dimen.embed_game_window_default_height) }
private var layoutWidthInPx = 0
private var layoutHeightInPx = 0
private var gameRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var isFullscreen = false
private var gameRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var resizingEnabled = false
private var isResizing = false
private var activeCorner = 0
private var isFreeResize = false
private var initialWinX = 0
private var initialWinY = 0
private var initialWidth = 0
private var initialHeight = 0
private var initialTouchX = 0f
private var initialTouchY = 0f
private val lockAspectRatioCheckBox: CheckBox by lazy { findViewById(R.id.lockAspectRatioCheckBox) }
private val cornerHandles = mutableListOf<View>()
private val screenBounds: android.graphics.Rect by lazy {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
windowManager.currentWindowMetrics.bounds
} else {
val size = Point()
windowManager.defaultDisplay.getRealSize(size)
android.graphics.Rect(0, 0, size.x, size.y)
}
}
private val maxAllowedWidth: Int get() = (screenBounds.width() * MAX_SCREEN_PERCENT).toInt()
private val maxAllowedHeight: Int get() = (screenBounds.height() * MAX_SCREEN_PERCENT).toInt()
private var lockedAspectRatio: Float = 1.6f
private val disableResizeHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val disableResizeRunnable = Runnable {
if (isResizing) return@Runnable // Keep it active user is resizing again
resizingEnabled = false
gameMenuFragment?.toggleDragButton(false)
lockAspectRatioCheckBox.visibility = View.GONE
cornerHandles.forEach { it.visibility = View.GONE }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -75,14 +125,54 @@ class EmbeddedGodotGame : GodotGame() {
val layoutParams = window.attributes
layoutParams.flags = layoutParams.flags or FLAG_NOT_TOUCH_MODAL or FLAG_WATCH_OUTSIDE_TOUCH
layoutParams.flags = layoutParams.flags and FLAG_DIM_BEHIND.inv()
layoutParams.gravity = Gravity.END or Gravity.BOTTOM
layoutParams.gravity = Gravity.TOP or Gravity.START
layoutWidthInPx = defaultWidthInPx
layoutHeightInPx = defaultHeightInPx
layoutParams.width = layoutWidthInPx
layoutParams.height = layoutHeightInPx
loadWindowBounds(layoutParams)
window.attributes = layoutParams
setupOverlayUI()
}
override fun getGodotAppLayout() = R.layout.godot_embedded_game_layout
private fun setupOverlayUI() {
lockAspectRatioCheckBox.isChecked = !isFreeResize
lockAspectRatioCheckBox.setOnCheckedChangeListener { _, isChecked ->
isFreeResize = !isChecked
hideResizeUI()
if (isChecked) {
val lp = window.attributes
lockedAspectRatio = lp.width.toFloat() / lp.height.toFloat().coerceAtLeast(1f)
}
saveWindowBounds(updatePiPParams = false)
}
cornerHandles.apply {
clear()
add(findViewById(R.id.handleTopLeft))
add(findViewById(R.id.handleTopRight))
add(findViewById(R.id.handleBottomLeft))
add(findViewById(R.id.handleBottomRight))
}
}
private fun updateLabelText(w: Int, h: Int) {
lockAspectRatioCheckBox.text = getString(R.string.lock_aspect_ratio_btn_text, w, h)
}
private fun showResizeUI() {
disableResizeHandler.removeCallbacks(disableResizeRunnable)
if (!resizingEnabled) {
resizingEnabled = true
lockAspectRatioCheckBox.visibility = View.VISIBLE
cornerHandles.forEach { it.visibility = View.VISIBLE }
}
}
private fun hideResizeUI() {
disableResizeHandler.removeCallbacks(disableResizeRunnable)
disableResizeHandler.postDelayed(disableResizeRunnable, RESIZE_UI_HIDE_DELAY_MS)
}
override fun setRequestedOrientation(requestedOrientation: Int) {
@@ -97,40 +187,222 @@ class EmbeddedGodotGame : GodotGame() {
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (isFullscreen) return super.dispatchTouchEvent(event)
val layoutParams = window.attributes
when (event.actionMasked) {
MotionEvent.ACTION_OUTSIDE -> {
if (!isFullscreen) {
if (gameMenuFragment?.isAlwaysOnTop() == true) {
enterPiPMode()
} else {
minimizeGameWindow()
if (gameMenuFragment?.isAlwaysOnTop() == true) {
updatePiPParams(aspectRatio = Rational(layoutWidthInPx, layoutHeightInPx))
enterPiPMode()
} else {
minimizeGameWindow()
}
}
MotionEvent.ACTION_DOWN -> {
if (resizingEnabled) {
// Check if the click is inside the label's bounds
val location = IntArray(2)
lockAspectRatioCheckBox.getLocationOnScreen(location)
val checkBoxRect = android.graphics.Rect(
location[0], location[1],
location[0] + lockAspectRatioCheckBox.width,
location[1] + lockAspectRatioCheckBox.height
)
if (checkBoxRect.contains(event.rawX.toInt(), event.rawY.toInt())) {
// Let the CheckBox handle the click itself
return super.dispatchTouchEvent(event)
}
activeCorner = getTouchedCorner(event.x, event.y, layoutParams.width, layoutParams.height)
if (activeCorner != 0) {
isResizing = true
initialTouchX = event.rawX
initialTouchY = event.rawY
initialWinX = layoutParams.x
initialWinY = layoutParams.y
initialWidth = layoutParams.width
initialHeight = layoutParams.height
return true
}
}
}
MotionEvent.ACTION_MOVE -> {
// val layoutParams = window.attributes
// TODO: Add logic to move the embedded window.
// window.attributes = layoutParams
if (resizingEnabled && isResizing) {
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
applyResizeLogic(layoutParams, dx, dy)
updateLabelText(layoutParams.width, layoutParams.height)
window.attributes = layoutParams
return true
}
}
MotionEvent.ACTION_UP -> {
if (isResizing) {
isResizing = false
saveWindowBounds()
hideResizeUI()
return true
}
}
}
return super.dispatchTouchEvent(event)
}
private fun applyResizeLogic(layoutParams: WindowManager.LayoutParams, dx: Int, dy: Int) {
var newW = initialWidth
var newH = initialHeight
when (activeCorner) {
Gravity.TOP or Gravity.START -> {
newW = (initialWidth - dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight - dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
layoutParams.x = initialWinX + (initialWidth - newW)
layoutParams.y = initialWinY + (initialHeight - newH)
}
Gravity.TOP or Gravity.END -> {
newW = (initialWidth + dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight - dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
layoutParams.y = initialWinY + (initialHeight - newH)
}
Gravity.BOTTOM or Gravity.START -> {
newW = (initialWidth - dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight + dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
layoutParams.x = initialWinX + (initialWidth - newW)
}
Gravity.BOTTOM or Gravity.END -> {
newW = (initialWidth + dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight + dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
}
}
// Final aspect-lock check
if (!isFreeResize) {
// Cap height based on both max height and width-constrained limit.
// effectiveMaxHeight helps ensure that the width (newW) calculated below is guaranteed to be <= maxAllowedWidth.
val maxHeightFromWidth = (maxAllowedWidth / lockedAspectRatio).toInt()
val effectiveMaxHeight = minOf(maxAllowedHeight, maxHeightFromWidth)
val clampedH = newH.coerceIn(MIN_WINDOW_SIZE, effectiveMaxHeight)
if (clampedH != newH) {
newH = clampedH
newW = (newH * lockedAspectRatio).toInt()
// Re-adjust pivots for the new clamped dimensions
if (activeCorner == (Gravity.TOP or Gravity.START) || activeCorner == (Gravity.TOP or Gravity.END)) {
layoutParams.y = initialWinY + (initialHeight - newH)
}
if (activeCorner == (Gravity.TOP or Gravity.START) || activeCorner == (Gravity.BOTTOM or Gravity.START)) {
layoutParams.x = initialWinX + (initialWidth - newW)
}
}
}
layoutParams.width = newW
layoutParams.height = newH
val hittingLimit = newW >= maxAllowedWidth || newH >= maxAllowedHeight
lockAspectRatioCheckBox.setTextColor(if (hittingLimit) Color.RED else Color.WHITE)
}
private fun getTouchedCorner(x: Float, y: Float, w: Int, h: Int): Int {
return when {
x < RESIZE_THRESHOLD && y < RESIZE_THRESHOLD -> Gravity.TOP or Gravity.START
x > w - RESIZE_THRESHOLD && y < RESIZE_THRESHOLD -> Gravity.TOP or Gravity.END
x < RESIZE_THRESHOLD && y > h - RESIZE_THRESHOLD -> Gravity.BOTTOM or Gravity.START
x > w - RESIZE_THRESHOLD && y > h - RESIZE_THRESHOLD -> Gravity.BOTTOM or Gravity.END
else -> 0
}
}
private fun saveWindowBounds(updatePiPParams: Boolean = true) {
if (isFullscreen) return
val layoutParams = window.attributes
layoutWidthInPx = layoutParams.width
layoutHeightInPx = layoutParams.height
getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().apply {
putInt(KEY_X, layoutParams.x)
putInt(KEY_Y, layoutParams.y)
putInt(KEY_WIDTH, layoutParams.width)
putInt(KEY_HEIGHT, layoutParams.height)
putBoolean(KEY_FREE_RESIZE, isFreeResize)
apply()
}
if (updatePiPParams) {
updatePiPParams(aspectRatio = Rational(layoutParams.width, layoutParams.height))
}
}
private fun loadWindowBounds(layoutParams: WindowManager.LayoutParams) {
val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
isFreeResize = prefs.getBoolean(KEY_FREE_RESIZE, false)
layoutWidthInPx = prefs.getInt(KEY_WIDTH, defaultWidthInPx)
layoutHeightInPx = prefs.getInt(KEY_HEIGHT, defaultHeightInPx)
layoutParams.x = prefs.getInt(KEY_X, screenBounds.width() - layoutWidthInPx)
layoutParams.y = prefs.getInt(KEY_Y, screenBounds.height() - layoutHeightInPx)
layoutParams.width = layoutWidthInPx
layoutParams.height = layoutHeightInPx
lockedAspectRatio = layoutParams.width.toFloat() / layoutParams.height.toFloat().coerceAtLeast(1f)
updateLabelText(layoutWidthInPx, layoutHeightInPx)
}
override fun dragGameWindow(view: View, event: MotionEvent): Boolean {
if (isFullscreen) return false
val lp = window.attributes
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
showResizeUI()
gameMenuFragment?.toggleDragButton(true)
initialTouchX = event.rawX
initialTouchY = event.rawY
initialWinX = lp.x
initialWinY = lp.y
return true
}
MotionEvent.ACTION_MOVE -> {
lp.x = (initialWinX + (event.rawX - initialTouchX)).toInt()
lp.y = (initialWinY + (event.rawY - initialTouchY)).toInt()
window.attributes = lp
return true
}
MotionEvent.ACTION_UP -> {
saveWindowBounds(updatePiPParams = false) // Only window position is changed, no need to update aspect ratio.
hideResizeUI()
return true
}
}
return false
}
override fun getEditorWindowInfo() = EMBEDDED_RUN_GAME_INFO
override fun getEditorGameEmbedMode() = GameMenuUtils.GameEmbedMode.ENABLED
override fun isGameEmbedded() = true
private fun updateWindowDimensions(widthInPx: Int, heightInPx: Int) {
val layoutParams = window.attributes
layoutParams.width = widthInPx
layoutParams.height = heightInPx
window.attributes = layoutParams
}
override fun isMinimizedButtonEnabled() = true
override fun isMinimizedButtonEnabled() = isFullscreen
override fun isCloseButtonEnabled() = true
@@ -140,24 +412,40 @@ class EmbeddedGodotGame : GodotGame() {
override fun isMenuBarCollapsable() = false
override fun isAlwaysOnTopSupported() = hasPiPSystemFeature()
override fun isDragButtonEnabled() = !isFullscreen
override fun isAlwaysOnTopSupported() = isPiPModeSupported()
override fun onFullScreenUpdated(enabled: Boolean) {
godot?.enableImmersiveMode(enabled)
isFullscreen = enabled
val layoutParams = window.attributes
if (enabled) {
layoutWidthInPx = FULL_SCREEN_WIDTH
layoutHeightInPx = FULL_SCREEN_HEIGHT
layoutWidthInPx = WindowManager.LayoutParams.MATCH_PARENT
layoutHeightInPx = WindowManager.LayoutParams.MATCH_PARENT
requestedOrientation = gameRequestedOrientation
layoutParams.x = 0
layoutParams.y = 0
if (resizingEnabled) {
disableResizeRunnable.run()
}
} else {
layoutWidthInPx = defaultWidthInPx
layoutHeightInPx = defaultHeightInPx
loadWindowBounds(layoutParams)
// Cache the last used orientation in fullscreen to reapply when re-entering fullscreen.
gameRequestedOrientation = requestedOrientation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
updateWindowDimensions(layoutWidthInPx, layoutHeightInPx)
gameMenuFragment?.refreshButtonsVisibility()
}
private fun updateWindowDimensions(widthInPx: Int, heightInPx: Int) {
val layoutParams = window.attributes
layoutParams.width = widthInPx
layoutParams.height = heightInPx
window.attributes = layoutParams
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
@@ -36,6 +36,7 @@ import android.os.Bundle
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
@@ -47,6 +48,7 @@ import androidx.fragment.app.Fragment
import org.godotengine.editor.BaseGodotEditor
import org.godotengine.editor.BaseGodotEditor.Companion.SNACKBAR_SHOW_DURATION_MS
import org.godotengine.editor.R
import org.godotengine.godot.feature.PictureInPictureProvider
import org.godotengine.godot.utils.DialogUtils
/**
@@ -65,7 +67,7 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
/**
* Used to be notified of events fired when interacting with the game menu.
*/
interface GameMenuListener {
interface GameMenuListener : PictureInPictureProvider {
/**
* Kotlin representation of the RuntimeNodeSelect::SelectMode enum in 'scene/debugger/scene_debugger.h'.
@@ -109,16 +111,16 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
fun isGameEmbeddingSupported(): Boolean
fun embedGameOnPlay(embedded: Boolean)
fun enterPiPMode() {}
fun minimizeGameWindow() {}
fun closeGameWindow() {}
fun dragGameWindow(view: View, event: MotionEvent): Boolean { return false}
fun isMinimizedButtonEnabled() = false
fun isFullScreenButtonEnabled() = false
fun isCloseButtonEnabled() = false
fun isPiPButtonEnabled() = false
fun isMenuBarCollapsable() = false
fun isDragButtonEnabled() = false
fun isAlwaysOnTopSupported() = false
fun onFullScreenUpdated(enabled: Boolean) {}
@@ -128,6 +130,9 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
private val collapseMenuButton: View? by lazy {
view?.findViewById(R.id.game_menu_collapse_button)
}
private val dragButton: View? by lazy {
view?.findViewById(R.id.game_menu_drag_button)
}
private val suspendButton: View? by lazy {
view?.findViewById(R.id.game_menu_suspend_button)
}
@@ -181,7 +186,6 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
PopupMenu(context, optionsButton).apply {
setOnMenuItemClickListener(this@GameMenuFragment)
inflate(R.menu.options_menu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
menu.setGroupDividerEnabled(true)
}
@@ -263,6 +267,7 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
val isCloseButtonEnabled = menuListener?.isCloseButtonEnabled() == true
val isPiPButtonEnabled = menuListener?.isPiPButtonEnabled() == true
val isMenuBarCollapsable = menuListener?.isMenuBarCollapsable() == true
val isDragButtonEnabled = menuListener?.isDragButtonEnabled() == true
// Show the divider if any of the window controls is visible
view.findViewById<View>(R.id.game_menu_window_controls_divider)?.isVisible =
@@ -270,7 +275,8 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
isFullScreenButtonEnabled ||
isCloseButtonEnabled ||
isPiPButtonEnabled ||
isMenuBarCollapsable
isMenuBarCollapsable ||
isDragButtonEnabled
collapseMenuButton?.apply {
isVisible = isMenuBarCollapsable
@@ -278,6 +284,13 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
collapseGameMenu()
}
}
dragButton?.apply {
isVisible = isDragButtonEnabled
setOnTouchListener { v, event ->
menuListener?.dragGameWindow(v, event) == true
}
}
fullscreenButton?.apply{
isVisible = isFullScreenButtonEnabled
setOnClickListener {
@@ -445,6 +458,10 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
internal fun isAlwaysOnTop() = isGameEmbedded && alwaysOnTopChecked
internal fun toggleDragButton(pressed: Boolean) {
dragButton?.isPressed = pressed
}
private fun collapseGameMenu() {
view?.isVisible = false
PreferenceManager.getDefaultSharedPreferences(context).edit {
@@ -461,6 +478,11 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
menuListener?.onGameMenuCollapsed(false)
}
internal fun refreshButtonsVisibility() {
minimizeButton?.isVisible = menuListener?.isMinimizedButtonEnabled() == true
dragButton?.isVisible = menuListener?.isDragButtonEnabled() == true
}
private fun updateAlwaysOnTop(enabled: Boolean) {
alwaysOnTopChecked = enabled
PreferenceManager.getDefaultSharedPreferences(context).edit {
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880L310,710L367,653L440,726L440,520L235,520L308,592L250,650L80,480L249,311L306,368L234,440L440,440L440,234L367,307L310,250L480,80L650,250L593,307L520,234L520,440L725,440L652,368L710,310L880,480L710,650L653,593L726,520L520,520L520,725L592,652L650,710L480,880Z"/>
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20,20 L20,4 M20,20 L4,20"
android:strokeColor="#007AFF"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>
@@ -181,6 +181,15 @@
android:src="@drawable/baseline_expand_less_24"
/>
<ImageButton
android:id="@+id/game_menu_drag_button"
style="?android:attr/borderlessButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/game_menu_selected_button_bg"
android:src="@drawable/drag_pan_24px"
/>
<ImageButton
android:id="@+id/game_menu_minimize_button"
style="?android:attr/borderlessButtonStyle"
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/game_menu_fragment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
/>
<FrameLayout
android:id="@+id/godot_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/game_menu_fragment_container"/>
<CheckBox
android:id="@+id/lockAspectRatioCheckBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="60dp"
android:background="#CC000000"
android:padding="8dp"
android:text="Lock Aspect Ratio"
android:textColor="#FFFFFF"
android:buttonTint="#FFFFFF"
android:checked="true"
android:visibility="gone" />
<View
android:id="@+id/handleTopLeft"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:background="@drawable/resize_handle"
android:rotation="180"
android:visibility="gone" />
<View
android:id="@+id/handleTopRight"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:background="@drawable/resize_handle"
android:rotation="270"
android:visibility="gone" />
<View
android:id="@+id/handleBottomLeft"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:background="@drawable/resize_handle"
android:rotation="90"
android:visibility="gone" />
<View
android:id="@+id/handleBottomRight"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:background="@drawable/resize_handle"
android:visibility="gone" />
</RelativeLayout>
@@ -20,4 +20,5 @@
<string name="show_game_resume_hint">Tap on \'Game\' to resume</string>
<string name="restart_embed_game_hint">Restart game to embed</string>
<string name="restart_non_embedded_game_hint">Restart Game to disable embedding</string>
<string name="lock_aspect_ratio_btn_text">Lock Aspect ratio (%d x %d)</string>
</resources>