feat: updated engine version to 4.4-rc1

This commit is contained in:
Sara 2025-02-23 14:38:14 +01:00
parent ee00efde1f
commit 21ba8e33af
5459 changed files with 1128836 additions and 198305 deletions

View file

@ -1,39 +0,0 @@
# Third-party libraries
This file list third-party libraries used in the Android source folder,
with their provenance and, when relevant, modifications made to those files.
## com.android.vending.billing
- Upstream: https://github.com/googlesamples/android-play-billing/tree/master/TrivialDrive/app/src/main
- Version: git (7a94c69, 2019)
- License: Apache 2.0
Overwrite the file `aidl/com/android/vending/billing/IInAppBillingService.aidl`.
## com.google.android.vending.expansion.downloader
- Upstream: https://github.com/google/play-apk-expansion/tree/master/apkx_library
- Version: git (9ecf54e, 2017)
- License: Apache 2.0
Overwrite all files under:
- `src/com/google/android/vending/expansion/downloader`
Some files have been modified for yet unclear reasons.
See the `patches/com.google.android.vending.expansion.downloader.patch` file.
## com.google.android.vending.licensing
- Upstream: https://github.com/google/play-licensing/tree/master/lvl_library/
- Version: git (eb57657, 2018) with modifications
- License: Apache 2.0
Overwrite all files under:
- `aidl/com/android/vending/licensing`
- `src/com/google/android/vending/licensing`
Some files have been modified to silence linter errors or fix downstream issues.
See the `patches/com.google.android.vending.licensing.patch` file.

View file

@ -51,7 +51,7 @@ android {
}
}
flavorDimensions "products"
flavorDimensions = ["products"]
productFlavors {
editor {}
template {}
@ -104,10 +104,10 @@ android {
}
boolean devBuild = buildType == "dev"
boolean debugSymbols = devBuild || isAndroidStudio()
boolean debugSymbols = devBuild
boolean runTests = devBuild
boolean productionBuild = !devBuild
boolean storeRelease = buildType == "release"
boolean productionBuild = storeRelease
def sconsTarget = flavorName
if (sconsTarget == "template") {

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:drawable/dialog_holo_dark_frame"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/snackbar_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text=""
android:textColor="@android:color/white"
android:textSize="14sp"
android:padding="8dp"/>
<Button
android:id="@+id/snackbar_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#00FFFFFF"
android:text="Action"
android:textColor="#61B7FC"
android:paddingHorizontal="8dp"/>
</LinearLayout>

View file

@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
WARNING: The content of this file must always match the constant 'platform/android/export/export_plugin.cpp#ICON_XML_TEMPLATE'.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/icon_background"/>
<foreground android:drawable="@mipmap/icon_foreground"/>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
This file is created to work alongside the icon.xml file.
If the user provides a Monochrome icon in the export settings, its data will be used to overwrite the icon.xml file.
We needed to create this file to get a reference for icon_monochrome.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/icon_background"/>
<foreground android:drawable="@mipmap/icon_foreground"/>
<monochrome android:drawable="@mipmap/icon_monochrome"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,4 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="text_edit_height">48dp</dimen>
<dimen name="button_height">48dp</dimen>
<dimen name="button_padding">10dp</dimen>
<dimen name="dialog_padding_horizontal">16dp</dimen>
<dimen name="dialog_padding_vertical">8dp</dimen>
<dimen name="snackbar_bottom_margin">10dp</dimen>
</resources>

View file

@ -55,4 +55,7 @@
<string name="kilobytes_per_second">%1$s KB/s</string>
<string name="time_remaining">Time remaining: %1$s</string>
<string name="time_remaining_notification">%1$s left</string>
<!-- Labels for the dialog action buttons -->
<string name="dialog_ok">OK</string>
</resources>

View file

@ -39,25 +39,32 @@ import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Color
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.*
import android.util.Log
import android.util.TypedValue
import android.view.*
import android.widget.FrameLayout
import androidx.annotation.Keep
import androidx.annotation.StringRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.google.android.vending.expansion.downloader.*
import org.godotengine.godot.error.Error
import org.godotengine.godot.input.GodotEditText
import org.godotengine.godot.input.GodotInputHandler
import org.godotengine.godot.io.FilePicker
import org.godotengine.godot.io.directory.DirectoryAccessHandler
import org.godotengine.godot.io.file.FileAccessHandler
import org.godotengine.godot.plugin.AndroidRuntimePlugin
import org.godotengine.godot.plugin.GodotPlugin
import org.godotengine.godot.plugin.GodotPluginRegistry
import org.godotengine.godot.tts.GodotTTS
import org.godotengine.godot.utils.CommandLineFileParser
import org.godotengine.godot.utils.DialogUtils
import org.godotengine.godot.utils.GodotNetUtils
import org.godotengine.godot.utils.PermissionsUtil
import org.godotengine.godot.utils.PermissionsUtil.requestPermission
@ -73,25 +80,31 @@ import java.io.InputStream
import java.lang.Exception
import java.security.MessageDigest
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Core component used to interface with the native layer of the engine.
*
* Can be hosted by [Activity], [Fragment] or [Service] android components, so long as its
* lifecycle methods are properly invoked.
*/
class Godot(private val context: Context) : SensorEventListener {
class Godot(private val context: Context) {
private companion object {
internal companion object {
private val TAG = Godot::class.java.simpleName
// Supported build flavors
const val EDITOR_FLAVOR = "editor"
const val TEMPLATE_FLAVOR = "template"
/**
* @return true if this is an editor build, false if this is a template build
*/
fun isEditorBuild() = BuildConfig.FLAVOR == EDITOR_FLAVOR
}
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val mSensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val mClipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
private val vibratorService: Vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
@ -99,34 +112,33 @@ class Godot(private val context: Context) : SensorEventListener {
private val pluginRegistry: GodotPluginRegistry by lazy {
GodotPluginRegistry.getPluginRegistry()
}
private val accelerometerEnabled = AtomicBoolean(false)
private val mAccelerometer: Sensor? by lazy {
mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
}
private val gravityEnabled = AtomicBoolean(false)
private val mGravity: Sensor? by lazy {
mSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
}
private val magnetometerEnabled = AtomicBoolean(false)
private val mMagnetometer: Sensor? by lazy {
mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
}
private val gyroscopeEnabled = AtomicBoolean(false)
private val mGyroscope: Sensor? by lazy {
mSensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
}
private val uiChangeListener = View.OnSystemUiVisibilityChangeListener { visibility: Int ->
if (visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) {
val decorView = requireActivity().window.decorView
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}}
val tts = GodotTTS(context)
val directoryAccessHandler = DirectoryAccessHandler(context)
val fileAccessHandler = FileAccessHandler(context)
val netUtils = GodotNetUtils(context)
private val commandLineFileParser = CommandLineFileParser()
private val godotInputHandler = GodotInputHandler(context, this)
/**
* Task to run when the engine terminates.
@ -154,13 +166,24 @@ class Godot(private val context: Context) : SensorEventListener {
private var renderViewInitialized = false
private var primaryHost: GodotHost? = null
/**
* Tracks whether we're in the RESUMED lifecycle state.
* See [onResume] and [onPause]
*/
private var resumed = false
/**
* Tracks whether [onGodotSetupCompleted] fired.
*/
private val godotMainLoopStarted = AtomicBoolean(false)
var io: GodotIO? = null
private var commandLine : MutableList<String> = ArrayList<String>()
private var xrMode = XRMode.REGULAR
private var expansionPackPath: String = ""
private var useApkExpansion = false
private var useImmersive = false
private val useImmersive = AtomicBoolean(false)
private var useDebugOpengl = false
private var darkMode = false
@ -210,7 +233,9 @@ class Godot(private val context: Context) : SensorEventListener {
window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
Log.v(TAG, "Initializing Godot plugin registry")
GodotPluginRegistry.initializePluginRegistry(this, primaryHost.getHostPlugins(this))
val runtimePlugins = mutableSetOf<GodotPlugin>(AndroidRuntimePlugin(this))
runtimePlugins.addAll(primaryHost.getHostPlugins(this))
GodotPluginRegistry.initializePluginRegistry(this, runtimePlugins)
if (io == null) {
io = GodotIO(activity)
}
@ -229,15 +254,9 @@ class Godot(private val context: Context) : SensorEventListener {
xrMode = XRMode.OPENXR
} else if (commandLine[i] == "--debug_opengl") {
useDebugOpengl = true
} else if (commandLine[i] == "--use_immersive") {
useImmersive = true
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or // hide nav bar
View.SYSTEM_UI_FLAG_FULLSCREEN or // hide status bar
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
registerUiChangeListener()
} else if (commandLine[i] == "--fullscreen") {
useImmersive.set(true)
newArgs.add(commandLine[i])
} else if (commandLine[i] == "--use_apk_expansion") {
useApkExpansion = true
} else if (hasExtra && commandLine[i] == "--apk_expansion_md5") {
@ -310,6 +329,54 @@ class Godot(private val context: Context) : SensorEventListener {
}
}
/**
* Toggle immersive mode.
* Must be called from the UI thread.
*/
fun enableImmersiveMode(enabled: Boolean, override: Boolean = false) {
val activity = getActivity() ?: return
val window = activity.window ?: return
if (!useImmersive.compareAndSet(!enabled, enabled) && !override) {
return
}
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
val controller = WindowInsetsControllerCompat(window, window.decorView)
if (enabled) {
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
val fullScreenThemeValue = TypedValue()
val hasStatusBar = if (activity.theme.resolveAttribute(android.R.attr.windowFullscreen, fullScreenThemeValue, true) && fullScreenThemeValue.type == TypedValue.TYPE_INT_BOOLEAN) {
fullScreenThemeValue.data == 0
} else {
// Fallback to checking the editor build
!isEditorBuild()
}
val types = if (hasStatusBar) {
WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars()
} else {
WindowInsetsCompat.Type.navigationBars()
}
controller.show(types)
}
}
/**
* Invoked from the render thread to toggle the immersive mode.
*/
@Keep
private fun nativeEnableImmersiveMode(enabled: Boolean) {
runOnUiThread {
enableImmersiveMode(enabled)
}
}
@Keep
fun isInImmersiveMode() = useImmersive.get()
/**
* Initializes the native layer of the Godot engine.
*
@ -413,13 +480,18 @@ class Godot(private val context: Context) : SensorEventListener {
// ...add to FrameLayout
containerLayout?.addView(editText)
renderView = if (usesVulkan()) {
if (!meetsVulkanRequirements(activity.packageManager)) {
if (meetsVulkanRequirements(activity.packageManager)) {
GodotVulkanRenderView(host, this, godotInputHandler)
} else if (canFallbackToOpenGL()) {
// Fallback to OpenGl.
GodotGLRenderView(host, this, godotInputHandler, xrMode, useDebugOpengl)
} else {
throw IllegalStateException(activity.getString(R.string.error_missing_vulkan_requirements_message))
}
GodotVulkanRenderView(host, this)
} else {
// Fallback to openGl
GodotGLRenderView(host, this, xrMode, useDebugOpengl)
// Fallback to OpenGl.
GodotGLRenderView(host, this, godotInputHandler, xrMode, useDebugOpengl)
}
if (host == primaryHost) {
@ -520,45 +592,47 @@ class Godot(private val context: Context) : SensorEventListener {
fun onResume(host: GodotHost) {
Log.v(TAG, "OnResume: $host")
resumed = true
if (host != primaryHost) {
return
}
renderView?.onActivityResumed()
if (mAccelerometer != null) {
mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
}
if (mGravity != null) {
mSensorManager.registerListener(this, mGravity, SensorManager.SENSOR_DELAY_GAME)
}
if (mMagnetometer != null) {
mSensorManager.registerListener(this, mMagnetometer, SensorManager.SENSOR_DELAY_GAME)
}
if (mGyroscope != null) {
mSensorManager.registerListener(this, mGyroscope, SensorManager.SENSOR_DELAY_GAME)
}
if (useImmersive) {
val window = requireActivity().window
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or // hide nav bar
View.SYSTEM_UI_FLAG_FULLSCREEN or // hide status bar
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
registerSensorsIfNeeded()
enableImmersiveMode(useImmersive.get(), true)
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainResume()
}
}
private fun registerSensorsIfNeeded() {
if (!resumed || !godotMainLoopStarted.get()) {
return
}
if (accelerometerEnabled.get() && mAccelerometer != null) {
mSensorManager.registerListener(godotInputHandler, mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
}
if (gravityEnabled.get() && mGravity != null) {
mSensorManager.registerListener(godotInputHandler, mGravity, SensorManager.SENSOR_DELAY_GAME)
}
if (magnetometerEnabled.get() && mMagnetometer != null) {
mSensorManager.registerListener(godotInputHandler, mMagnetometer, SensorManager.SENSOR_DELAY_GAME)
}
if (gyroscopeEnabled.get() && mGyroscope != null) {
mSensorManager.registerListener(godotInputHandler, mGyroscope, SensorManager.SENSOR_DELAY_GAME)
}
}
fun onPause(host: GodotHost) {
Log.v(TAG, "OnPause: $host")
resumed = false
if (host != primaryHost) {
return
}
renderView?.onActivityPaused()
mSensorManager.unregisterListener(this)
mSensorManager.unregisterListener(godotInputHandler)
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainPause()
}
@ -604,6 +678,9 @@ class Godot(private val context: Context) : SensorEventListener {
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainActivityResult(requestCode, resultCode, data)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FilePicker.handleActivityResult(context, requestCode, resultCode, data)
}
}
/**
@ -659,6 +736,17 @@ class Godot(private val context: Context) : SensorEventListener {
*/
private fun onGodotMainLoopStarted() {
Log.v(TAG, "OnGodotMainLoopStarted")
godotMainLoopStarted.set(true)
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")))
gyroscopeEnabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_gyroscope")))
magnetometerEnabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_magnetometer")))
runOnUiThread {
registerSensorsIfNeeded()
enableImmersiveMode(useImmersive.get(), true)
}
for (plugin in pluginRegistry.allPlugins) {
plugin.onGodotMainLoopStarted()
@ -679,11 +767,6 @@ class Godot(private val context: Context) : SensorEventListener {
primaryHost?.onGodotRestartRequested(this)
}
private fun registerUiChangeListener() {
val decorView = requireActivity().window.decorView
decorView.setOnSystemUiVisibilityChangeListener(uiChangeListener)
}
fun alert(
@StringRes messageResId: Int,
@StringRes titleResId: Int,
@ -701,7 +784,7 @@ class Godot(private val context: Context) : SensorEventListener {
val builder = AlertDialog.Builder(activity)
builder.setMessage(message).setTitle(title)
builder.setPositiveButton(
"OK"
R.string.dialog_ok
) { dialog: DialogInterface, id: Int ->
okCallback?.run()
dialog.cancel()
@ -740,9 +823,33 @@ class Godot(private val context: Context) : SensorEventListener {
* Returns true if `Vulkan` is used for rendering.
*/
private fun usesVulkan(): Boolean {
val renderer = GodotLib.getGlobal("rendering/renderer/rendering_method")
val renderingDevice = GodotLib.getGlobal("rendering/rendering_device/driver")
return ("forward_plus" == renderer || "mobile" == renderer) && "vulkan" == renderingDevice
var rendererSource = "ProjectSettings"
var renderer = GodotLib.getGlobal("rendering/renderer/rendering_method")
var renderingDeviceSource = "ProjectSettings"
var renderingDevice = GodotLib.getGlobal("rendering/rendering_device/driver")
val cmdline = getCommandLine()
var index = cmdline.indexOf("--rendering-method")
if (index > -1 && cmdline.size > index + 1) {
rendererSource = "CommandLine"
renderer = cmdline.get(index + 1)
}
index = cmdline.indexOf("--rendering-driver")
if (index > -1 && cmdline.size > index + 1) {
renderingDeviceSource = "CommandLine"
renderingDevice = cmdline.get(index + 1)
}
val result = ("forward_plus" == renderer || "mobile" == renderer) && "vulkan" == renderingDevice
Log.d(TAG, """usesVulkan(): ${result}
renderingDevice: ${renderingDevice} (${renderingDeviceSource})
renderer: ${renderer} (${rendererSource})""")
return result
}
/**
* Returns true if can fallback to OpenGL.
*/
private fun canFallbackToOpenGL(): Boolean {
return java.lang.Boolean.parseBoolean(GodotLib.getGlobal("rendering/rendering_device/fallback_to_opengl3"))
}
/**
@ -794,11 +901,6 @@ class Godot(private val context: Context) : SensorEventListener {
return mClipboard.hasPrimaryClip()
}
/**
* @return true if this is an editor build, false if this is a template build
*/
fun isEditorBuild() = BuildConfig.FLAVOR == EDITOR_FLAVOR
fun getClipboard(): String {
val clipData = mClipboard.primaryClip ?: return ""
val text = clipData.getItemAt(0).text ?: return ""
@ -810,6 +912,51 @@ class Godot(private val context: Context) : SensorEventListener {
mClipboard.setPrimaryClip(clip)
}
@Keep
private fun showFilePicker(currentDirectory: String, filename: String, fileMode: Int, filters: Array<String>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FilePicker.showFilePicker(context, getActivity(), currentDirectory, filename, fileMode, filters)
}
}
/**
* This method shows a dialog with multiple buttons.
*
* @param title The title of the dialog.
* @param message The message displayed in the dialog.
* @param buttons An array of button labels to display.
*/
@Keep
private fun showDialog(title: String, message: String, buttons: Array<String>) {
getActivity()?.let { DialogUtils.showDialog(it, title, message, buttons) }
}
/**
* This method shows a dialog with a text input field, allowing the user to input text.
*
* @param title The title of the input dialog.
* @param message The message displayed in the input dialog.
* @param existingText The existing text that will be pre-filled in the input field.
*/
@Keep
private fun showInputDialog(title: String, message: String, existingText: String) {
getActivity()?.let { DialogUtils.showInputDialog(it, title, message, existingText) }
}
@Keep
private fun getAccentColor(): Int {
val value = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorAccent, value, true)
return value.data
}
@Keep
private fun getBaseColor(): Int {
val value = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorBackground, value, true)
return value.data
}
/**
* Destroys the Godot Engine and kill the process it's running in.
*/
@ -847,88 +994,12 @@ class Godot(private val context: Context) : SensorEventListener {
}
fun onBackPressed() {
var shouldQuit = true
for (plugin in pluginRegistry.allPlugins) {
if (plugin.onMainBackPressed()) {
shouldQuit = false
}
}
if (shouldQuit) {
renderView?.queueOnRenderThread { GodotLib.back() }
plugin.onMainBackPressed()
}
renderView?.queueOnRenderThread { GodotLib.back() }
}
private fun getRotatedValues(values: FloatArray?): FloatArray? {
if (values == null || values.size != 3) {
return null
}
val rotatedValues = FloatArray(3)
when (windowManager.defaultDisplay.rotation) {
Surface.ROTATION_0 -> {
rotatedValues[0] = values[0]
rotatedValues[1] = values[1]
rotatedValues[2] = values[2]
}
Surface.ROTATION_90 -> {
rotatedValues[0] = -values[1]
rotatedValues[1] = values[0]
rotatedValues[2] = values[2]
}
Surface.ROTATION_180 -> {
rotatedValues[0] = -values[0]
rotatedValues[1] = -values[1]
rotatedValues[2] = values[2]
}
Surface.ROTATION_270 -> {
rotatedValues[0] = values[1]
rotatedValues[1] = -values[0]
rotatedValues[2] = values[2]
}
}
return rotatedValues
}
override fun onSensorChanged(event: SensorEvent) {
if (renderView == null) {
return
}
val rotatedValues = getRotatedValues(event.values)
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
rotatedValues?.let {
renderView?.queueOnRenderThread {
GodotLib.accelerometer(-it[0], -it[1], -it[2])
}
}
}
Sensor.TYPE_GRAVITY -> {
rotatedValues?.let {
renderView?.queueOnRenderThread {
GodotLib.gravity(-it[0], -it[1], -it[2])
}
}
}
Sensor.TYPE_MAGNETIC_FIELD -> {
rotatedValues?.let {
renderView?.queueOnRenderThread {
GodotLib.magnetometer(-it[0], -it[1], -it[2])
}
}
}
Sensor.TYPE_GYROSCOPE -> {
rotatedValues?.let {
renderView?.queueOnRenderThread {
GodotLib.gyroscope(it[0], it[1], it[2])
}
}
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
/**
* Used by the native code (java_godot_wrapper.h) to vibrate the device.
* @param durationMs
@ -985,7 +1056,8 @@ class Godot(private val context: Context) : SensorEventListener {
}
fun requestPermission(name: String?): Boolean {
return requestPermission(name, getActivity())
val activity = getActivity() ?: return false
return requestPermission(name, activity)
}
fun requestPermissions(): Boolean {
@ -996,11 +1068,25 @@ class Godot(private val context: Context) : SensorEventListener {
return PermissionsUtil.getGrantedPermissions(getActivity())
}
/**
* Returns true if this is the Godot editor.
*/
fun isEditorHint() = isEditorBuild() && GodotLib.isEditorHint()
/**
* Returns true if this is the Godot project manager.
*/
fun isProjectManagerHint() = isEditorBuild() && GodotLib.isProjectManagerHint()
/**
* Return true if the given feature is supported.
*/
@Keep
private fun hasFeature(feature: String): Boolean {
if (primaryHost?.supportsFeature(feature) ?: false) {
return true;
}
for (plugin in pluginRegistry.allPlugins) {
if (plugin.supportsFeature(feature)) {
return true
@ -1063,12 +1149,12 @@ class Godot(private val context: Context) : SensorEventListener {
@Keep
private fun initInputDevices() {
renderView?.initInputDevices()
godotInputHandler.initInputDevices()
}
@Keep
private fun createNewGodotInstance(args: Array<String>): Int {
return primaryHost?.onNewGodotInstanceRequested(args) ?: 0
return primaryHost?.onNewGodotInstanceRequested(args) ?: -1
}
@Keep
@ -1085,4 +1171,25 @@ class Godot(private val context: Context) : SensorEventListener {
private fun nativeDumpBenchmark(benchmarkFile: String) {
dumpBenchmark(fileAccessHandler, benchmarkFile)
}
@Keep
private fun nativeSignApk(inputPath: String,
outputPath: String,
keystorePath: String,
keystoreUser: String,
keystorePassword: String): Int {
val signResult = primaryHost?.signApk(inputPath, outputPath, keystorePath, keystoreUser, keystorePassword) ?: Error.ERR_UNAVAILABLE
return signResult.toNativeValue()
}
@Keep
private fun nativeVerifyApk(apkPath: String): Int {
val verifyResult = primaryHost?.verifyApk(apkPath) ?: Error.ERR_UNAVAILABLE
return verifyResult.toNativeValue()
}
@Keep
private fun nativeOnEditorWorkspaceSelected(workspace: String) {
primaryHost?.onEditorWorkspaceSelected(workspace)
}
}

View file

@ -31,6 +31,7 @@
package org.godotengine.godot
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
@ -53,19 +54,31 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
private val TAG = GodotActivity::class.java.simpleName
@JvmStatic
protected val EXTRA_FORCE_QUIT = "force_quit_requested"
protected val EXTRA_COMMAND_LINE_PARAMS = "command_line_params"
@JvmStatic
protected val EXTRA_NEW_LAUNCH = "new_launch_requested"
// This window must not match those in BaseGodotEditor.RUN_GAME_INFO etc
@JvmStatic
private final val DEFAULT_WINDOW_ID = 664;
}
private val commandLineParams = ArrayList<String>()
/**
* Interaction with the [Godot] object is delegated to the [GodotFragment] class.
*/
protected var godotFragment: GodotFragment? = null
private set
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
val params = intent.getStringArrayExtra(EXTRA_COMMAND_LINE_PARAMS)
Log.d(TAG, "Starting intent $intent with parameters ${params.contentToString()}")
commandLineParams.addAll(params ?: emptyArray())
super.onCreate(savedInstanceState)
setContentView(getGodotAppLayout())
handleStartIntent(intent, true)
@ -81,6 +94,29 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
}
}
override fun onNewGodotInstanceRequested(args: Array<String>): Int {
Log.d(TAG, "Restarting with parameters ${args.contentToString()}")
val intent = Intent()
.setComponent(ComponentName(this, javaClass.name))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_COMMAND_LINE_PARAMS, args)
triggerRebirth(null, intent)
// fake 'process' id returned by create_instance() etc
return DEFAULT_WINDOW_ID;
}
protected fun triggerRebirth(bundle: Bundle?, intent: Intent) {
// Launch a new activity
val godot = godot
if (godot != null) {
godot.destroyAndKillProcess {
ProcessPhoenix.triggerRebirth(this, bundle, intent)
}
} else {
ProcessPhoenix.triggerRebirth(this, bundle, intent)
}
}
@LayoutRes
protected open fun getGodotAppLayout() = R.layout.godot_app_layout
@ -128,12 +164,6 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
}
private fun handleStartIntent(intent: Intent, newLaunch: Boolean) {
val forceQuitRequested = intent.getBooleanExtra(EXTRA_FORCE_QUIT, false)
if (forceQuitRequested) {
Log.d(TAG, "Force quit requested, terminating..")
ProcessPhoenix.forceQuit(this)
return
}
if (!newLaunch) {
val newLaunchRequested = intent.getBooleanExtra(EXTRA_NEW_LAUNCH, false)
if (newLaunchRequested) {
@ -184,4 +214,6 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
protected open fun initGodotInstance(): GodotFragment {
return GodotFragment()
}
override fun getCommandLine(): MutableList<String> = commandLineParams
}

View file

@ -30,6 +30,7 @@
package org.godotengine.godot;
import org.godotengine.godot.error.Error;
import org.godotengine.godot.plugin.GodotPlugin;
import org.godotengine.godot.utils.BenchmarkUtils;
@ -473,7 +474,7 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
if (parentHost != null) {
return parentHost.onNewGodotInstanceRequested(args);
}
return 0;
return -1;
}
@Override
@ -484,4 +485,35 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
}
return Collections.emptySet();
}
@Override
public Error signApk(@NonNull String inputPath, @NonNull String outputPath, @NonNull String keystorePath, @NonNull String keystoreUser, @NonNull String keystorePassword) {
if (parentHost != null) {
return parentHost.signApk(inputPath, outputPath, keystorePath, keystoreUser, keystorePassword);
}
return Error.ERR_UNAVAILABLE;
}
@Override
public Error verifyApk(@NonNull String apkPath) {
if (parentHost != null) {
return parentHost.verifyApk(apkPath);
}
return Error.ERR_UNAVAILABLE;
}
@Override
public boolean supportsFeature(String featureTag) {
if (parentHost != null) {
return parentHost.supportsFeature(featureTag);
}
return false;
}
@Override
public void onEditorWorkspaceSelected(String workspace) {
if (parentHost != null) {
parentHost.onEditorWorkspaceSelected(workspace);
}
}
}

View file

@ -83,12 +83,12 @@ class GodotGLRenderView extends GLSurfaceView implements GodotRenderView {
private final GodotRenderer godotRenderer;
private final SparseArray<PointerIcon> customPointerIcons = new SparseArray<>();
public GodotGLRenderView(GodotHost host, Godot godot, XRMode xrMode, boolean useDebugOpengl) {
public GodotGLRenderView(GodotHost host, Godot godot, GodotInputHandler inputHandler, XRMode xrMode, boolean useDebugOpengl) {
super(host.getActivity());
this.host = host;
this.godot = godot;
this.inputHandler = new GodotInputHandler(this);
this.inputHandler = inputHandler;
this.godotRenderer = new GodotRenderer();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setPointerIcon(PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_DEFAULT));
@ -101,11 +101,6 @@ class GodotGLRenderView extends GLSurfaceView implements GodotRenderView {
return this;
}
@Override
public void initInputDevices() {
this.inputHandler.initInputDevices();
}
@Override
public void queueOnRenderThread(Runnable event) {
queueEvent(event);
@ -144,11 +139,6 @@ class GodotGLRenderView extends GLSurfaceView implements GodotRenderView {
requestRenderThreadExitAndWait();
}
@Override
public void onBackPressed() {
godot.onBackPressed();
}
@Override
public GodotInputHandler getInputHandler() {
return inputHandler;

View file

@ -30,10 +30,13 @@
package org.godotengine.godot;
import org.godotengine.godot.error.Error;
import org.godotengine.godot.plugin.GodotPlugin;
import android.app.Activity;
import androidx.annotation.NonNull;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@ -89,7 +92,7 @@ public interface GodotHost {
* @return the id of the new instance. See {@code onGodotForceQuit}
*/
default int onNewGodotInstanceRequested(String[] args) {
return 0;
return -1;
}
/**
@ -108,4 +111,43 @@ public interface GodotHost {
default Set<GodotPlugin> getHostPlugins(Godot engine) {
return Collections.emptySet();
}
/**
* Signs the given Android apk
*
* @param inputPath Path to the apk that should be signed
* @param outputPath Path for the signed output apk; can be the same as inputPath
* @param keystorePath Path to the keystore to use for signing the apk
* @param keystoreUser Keystore user credential
* @param keystorePassword Keystore password credential
*
* @return {@link Error#OK} if signing is successful
*/
default Error signApk(@NonNull String inputPath, @NonNull String outputPath, @NonNull String keystorePath, @NonNull String keystoreUser, @NonNull String keystorePassword) {
return Error.ERR_UNAVAILABLE;
}
/**
* Verifies the given Android apk is signed
*
* @param apkPath Path to the apk that should be verified
* @return {@link Error#OK} if verification was successful
*/
default Error verifyApk(@NonNull String apkPath) {
return Error.ERR_UNAVAILABLE;
}
/**
* Returns whether the given feature tag is supported.
*
* @see <a href="https://docs.godotengine.org/en/stable/tutorials/export/feature_tags.html">Feature tags</a>
*/
default boolean supportsFeature(String featureTag) {
return false;
}
/**
* Invoked on the render thread when an editor workspace has been selected.
*/
default void onEditorWorkspaceSelected(String workspace) {}
}

View file

@ -30,13 +30,12 @@
package org.godotengine.godot;
import org.godotengine.godot.error.Error;
import org.godotengine.godot.input.GodotEditText;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.graphics.Point;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Build;
@ -120,10 +119,10 @@ public class GodotIO {
}
activity.startActivity(intent);
return 0;
return Error.OK.toNativeValue();
} catch (Exception e) {
Log.e(TAG, "Unable to open uri " + uriString, e);
return 1;
return Error.FAILED.toNativeValue();
}
}
@ -131,6 +130,18 @@ public class GodotIO {
return activity.getCacheDir().getAbsolutePath();
}
public String getTempDir() {
File tempDir = new File(getCacheDir() + "/tmp");
if (!tempDir.exists()) {
if (!tempDir.mkdirs()) {
Log.e(TAG, "Unable to create temp dir");
}
}
return tempDir.getAbsolutePath();
}
public String getDataDir() {
return activity.getFilesDir().getAbsolutePath();
}
@ -216,6 +227,14 @@ public class GodotIO {
return result;
}
public boolean hasHardwareKeyboard() {
if (edit != null) {
return edit.hasHardwareKeyboard();
} else {
return false;
}
}
public void showKeyboard(String p_existing_text, int p_type, int p_max_input_length, int p_cursor_start, int p_cursor_end) {
if (edit != null) {
edit.showKeyboard(p_existing_text, GodotEditText.VirtualKeyboardType.values()[p_type], p_max_input_length, p_cursor_start, p_cursor_end);

View file

@ -35,6 +35,7 @@ import org.godotengine.godot.io.directory.DirectoryAccessHandler;
import org.godotengine.godot.io.file.FileAccessHandler;
import org.godotengine.godot.tts.GodotTTS;
import org.godotengine.godot.utils.GodotNetUtils;
import org.godotengine.godot.variant.Callable;
import android.app.Activity;
import android.content.res.AssetManager;
@ -195,21 +196,55 @@ public class GodotLib {
*/
public static native String getEditorSetting(String settingKey);
/**
* Update the 'key' editor setting with the given data. Must be called on the render thread.
* @param key
* @param data
*/
public static native void setEditorSetting(String key, Object data);
/**
* Used to access project metadata from the editor settings. Must be accessed on the render thread.
* @param section
* @param key
* @param defaultValue
* @return
*/
public static native Object getEditorProjectMetadata(String section, String key, Object defaultValue);
/**
* Set the project metadata to the editor settings. Must be accessed on the render thread.
* @param section
* @param key
* @param data
*/
public static native void setEditorProjectMetadata(String section, String key, Object data);
/**
* Invoke method |p_method| on the Godot object specified by |p_id|
* @param p_id Id of the Godot object to invoke
* @param p_method Name of the method to invoke
* @param p_params Parameters to use for method invocation
*
* @deprecated Use {@link Callable#call(long, String, Object...)} instead.
*/
public static native void callobject(long p_id, String p_method, Object[] p_params);
@Deprecated
public static void callobject(long p_id, String p_method, Object[] p_params) {
Callable.call(p_id, p_method, p_params);
}
/**
* Invoke method |p_method| on the Godot object specified by |p_id| during idle time.
* @param p_id Id of the Godot object to invoke
* @param p_method Name of the method to invoke
* @param p_params Parameters to use for method invocation
*
* @deprecated Use {@link Callable#callDeferred(long, String, Object...)} instead.
*/
public static native void calldeferred(long p_id, String p_method, Object[] p_params);
@Deprecated
public static void calldeferred(long p_id, String p_method, Object[] p_params) {
Callable.callDeferred(p_id, p_method, p_params);
}
/**
* Forward the results from a permission request.
@ -224,6 +259,11 @@ public class GodotLib {
*/
public static native void onNightModeChanged();
/**
* Invoked on the file picker closed.
*/
public static native void filePickerCallback(boolean p_ok, String[] p_selected_paths);
/**
* Invoked on the GL thread to configure the height of the virtual keyboard.
*/
@ -246,4 +286,13 @@ public class GodotLib {
* dispatched from the UI thread.
*/
public static native boolean shouldDispatchInputToRenderThread();
/**
* @return the project resource directory
*/
public static native String getProjectResourceDir();
static native boolean isEditorHint();
static native boolean isProjectManagerHint();
}

View file

@ -31,14 +31,13 @@
package org.godotengine.godot;
import org.godotengine.godot.input.GodotInputHandler;
import org.godotengine.godot.utils.DeviceUtils;
import android.view.SurfaceView;
public interface GodotRenderView {
SurfaceView getView();
void initInputDevices();
/**
* Starts the thread that will drive Godot's rendering.
*/
@ -59,15 +58,17 @@ public interface GodotRenderView {
void onActivityDestroyed();
void onBackPressed();
GodotInputHandler getInputHandler();
void configurePointerIcon(int pointerType, String imagePath, float hotSpotX, float hotSpotY);
void setPointerIcon(int pointerType);
/**
* @return true if pointer capture is supported.
*/
default boolean canCapturePointer() {
return getInputHandler().canCapturePointer();
// Pointer capture is not supported on native XR devices.
return !DeviceUtils.isNativeXRDevice(getView().getContext()) && getInputHandler().canCapturePointer();
}
}

View file

@ -57,17 +57,18 @@ class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderView {
private final VkRenderer mRenderer;
private final SparseArray<PointerIcon> customPointerIcons = new SparseArray<>();
public GodotVulkanRenderView(GodotHost host, Godot godot) {
public GodotVulkanRenderView(GodotHost host, Godot godot, GodotInputHandler inputHandler) {
super(host.getActivity());
this.host = host;
this.godot = godot;
mInputHandler = new GodotInputHandler(this);
mInputHandler = inputHandler;
mRenderer = new VkRenderer();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setPointerIcon(PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_DEFAULT));
}
setFocusableInTouchMode(true);
setClickable(false);
}
@Override
@ -80,11 +81,6 @@ class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderView {
return this;
}
@Override
public void initInputDevices() {
mInputHandler.initInputDevices();
}
@Override
public void queueOnRenderThread(Runnable event) {
queueOnVkThread(event);
@ -123,11 +119,6 @@ class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderView {
requestRenderThreadExitAndWait();
}
@Override
public void onBackPressed() {
godot.onBackPressed();
}
@Override
public GodotInputHandler getInputHandler() {
return mInputHandler;
@ -142,17 +133,17 @@ class GodotVulkanRenderView extends VkSurfaceView implements GodotRenderView {
@Override
public boolean onKeyUp(final int keyCode, KeyEvent event) {
return mInputHandler.onKeyUp(keyCode, event);
return mInputHandler.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event);
}
@Override
public boolean onKeyDown(final int keyCode, KeyEvent event) {
return mInputHandler.onKeyDown(keyCode, event);
return mInputHandler.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event);
}
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
return mInputHandler.onGenericMotionEvent(event);
return mInputHandler.onGenericMotionEvent(event) || super.onGenericMotionEvent(event);
}
@Override

View file

@ -0,0 +1,100 @@
/**************************************************************************/
/* Error.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.godot.error
/**
* Godot error list.
*
* This enum MUST match its native counterpart in 'core/error/error_list.h'
*/
enum class Error(private val description: String) {
OK("OK"), // (0)
FAILED("Failed"), ///< Generic fail error
ERR_UNAVAILABLE("Unavailable"), ///< What is requested is unsupported/unavailable
ERR_UNCONFIGURED("Unconfigured"), ///< The object being used hasn't been properly set up yet
ERR_UNAUTHORIZED("Unauthorized"), ///< Missing credentials for requested resource
ERR_PARAMETER_RANGE_ERROR("Parameter out of range"), ///< Parameter given out of range (5)
ERR_OUT_OF_MEMORY("Out of memory"), ///< Out of memory
ERR_FILE_NOT_FOUND("File not found"),
ERR_FILE_BAD_DRIVE("File: Bad drive"),
ERR_FILE_BAD_PATH("File: Bad path"),
ERR_FILE_NO_PERMISSION("File: Permission denied"), // (10)
ERR_FILE_ALREADY_IN_USE("File already in use"),
ERR_FILE_CANT_OPEN("Can't open file"),
ERR_FILE_CANT_WRITE("Can't write file"),
ERR_FILE_CANT_READ("Can't read file"),
ERR_FILE_UNRECOGNIZED("File unrecognized"), // (15)
ERR_FILE_CORRUPT("File corrupt"),
ERR_FILE_MISSING_DEPENDENCIES("Missing dependencies for file"),
ERR_FILE_EOF("End of file"),
ERR_CANT_OPEN("Can't open"), ///< Can't open a resource/socket/file
ERR_CANT_CREATE("Can't create"), // (20)
ERR_QUERY_FAILED("Query failed"),
ERR_ALREADY_IN_USE("Already in use"),
ERR_LOCKED("Locked"), ///< resource is locked
ERR_TIMEOUT("Timeout"),
ERR_CANT_CONNECT("Can't connect"), // (25)
ERR_CANT_RESOLVE("Can't resolve"),
ERR_CONNECTION_ERROR("Connection error"),
ERR_CANT_ACQUIRE_RESOURCE("Can't acquire resource"),
ERR_CANT_FORK("Can't fork"),
ERR_INVALID_DATA("Invalid data"), ///< Data passed is invalid (30)
ERR_INVALID_PARAMETER("Invalid parameter"), ///< Parameter passed is invalid
ERR_ALREADY_EXISTS("Already exists"), ///< When adding, item already exists
ERR_DOES_NOT_EXIST("Does not exist"), ///< When retrieving/erasing, if item does not exist
ERR_DATABASE_CANT_READ("Can't read database"), ///< database is full
ERR_DATABASE_CANT_WRITE("Can't write database"), ///< database is full (35)
ERR_COMPILATION_FAILED("Compilation failed"),
ERR_METHOD_NOT_FOUND("Method not found"),
ERR_LINK_FAILED("Link failed"),
ERR_SCRIPT_FAILED("Script failed"),
ERR_CYCLIC_LINK("Cyclic link detected"), // (40)
ERR_INVALID_DECLARATION("Invalid declaration"),
ERR_DUPLICATE_SYMBOL("Duplicate symbol"),
ERR_PARSE_ERROR("Parse error"),
ERR_BUSY("Busy"),
ERR_SKIP("Skip"), // (45)
ERR_HELP("Help"), ///< user requested help!!
ERR_BUG("Bug"), ///< a bug in the software certainly happened, due to a double check failing or unexpected behavior.
ERR_PRINTER_ON_FIRE("Printer on fire"); /// the parallel port printer is engulfed in flames
companion object {
internal fun fromNativeValue(nativeValue: Int): Error? {
return Error.entries.getOrNull(nativeValue)
}
}
fun toNativeValue(): Int = this.ordinal
override fun toString(): String {
return description
}
}

View file

@ -264,7 +264,7 @@ public class GodotEditText extends EditText {
isModifiedKey;
}
boolean hasHardwareKeyboard() {
public boolean hasHardwareKeyboard() {
Configuration config = getResources().getConfiguration();
boolean hasHardwareKeyboardConfig = config.keyboard != Configuration.KEYBOARD_NOKEYS &&
config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO;

View file

@ -32,10 +32,14 @@ package org.godotengine.godot.input;
import static org.godotengine.godot.utils.GLUtils.DEBUG;
import org.godotengine.godot.Godot;
import org.godotengine.godot.GodotLib;
import org.godotengine.godot.GodotRenderView;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.input.InputManager;
import android.os.Build;
import android.util.Log;
@ -46,6 +50,10 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.Surface;
import android.view.WindowManager;
import androidx.annotation.NonNull;
import java.util.Collections;
import java.util.HashSet;
@ -54,7 +62,7 @@ import java.util.Set;
/**
* Handles input related events for the {@link GodotRenderView} view.
*/
public class GodotInputHandler implements InputManager.InputDeviceListener {
public class GodotInputHandler implements InputManager.InputDeviceListener, SensorEventListener {
private static final String TAG = GodotInputHandler.class.getSimpleName();
private static final int ROTARY_INPUT_VERTICAL_AXIS = 1;
@ -64,8 +72,9 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
private final SparseArray<Joystick> mJoysticksDevices = new SparseArray<>(4);
private final HashSet<Integer> mHardwareKeyboardIds = new HashSet<>();
private final GodotRenderView mRenderView;
private final Godot godot;
private final InputManager mInputManager;
private final WindowManager windowManager;
private final GestureDetector gestureDetector;
private final ScaleGestureDetector scaleGestureDetector;
private final GodotGestureHandler godotGestureHandler;
@ -77,12 +86,13 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
private int rotaryInputAxis = ROTARY_INPUT_VERTICAL_AXIS;
public GodotInputHandler(GodotRenderView godotView) {
final Context context = godotView.getView().getContext();
mRenderView = godotView;
public GodotInputHandler(Context context, Godot godot) {
this.godot = godot;
mInputManager = (InputManager)context.getSystemService(Context.INPUT_SERVICE);
mInputManager.registerInputDeviceListener(this, null);
windowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
this.godotGestureHandler = new GodotGestureHandler(this);
this.gestureDetector = new GestureDetector(context, godotGestureHandler);
this.gestureDetector.setIsLongpressEnabled(false);
@ -144,10 +154,6 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
}
public boolean onKeyUp(final int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
return true;
}
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
return false;
}
@ -173,13 +179,6 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
}
public boolean onKeyDown(final int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
mRenderView.onBackPressed();
// press 'back' button should not terminate program
//normal handle 'back' event in game logic
return true;
}
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
return false;
}
@ -507,7 +506,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
return handleTouchEvent(event, eventActionOverride, doubleTap);
}
private static float getEventTiltX(MotionEvent event) {
static float getEventTiltX(MotionEvent event) {
// Orientation is returned as a radian value between 0 to pi clockwise or 0 to -pi counterclockwise.
final float orientation = event.getOrientation();
@ -520,7 +519,7 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
return (float)-Math.sin(orientation) * tiltMult;
}
private static float getEventTiltY(MotionEvent event) {
static float getEventTiltY(MotionEvent event) {
// Orientation is returned as a radian value between 0 to pi clockwise or 0 to -pi counterclockwise.
final float orientation = event.getOrientation();
@ -579,6 +578,11 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
}
boolean handleMouseEvent(int eventAction, int buttonsMask, float x, float y, float deltaX, float deltaY, boolean doubleClick, boolean sourceMouseRelative, float pressure, float tiltX, float tiltY) {
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return false;
}
// Fix the buttonsMask
switch (eventAction) {
case MotionEvent.ACTION_CANCEL:
@ -594,7 +598,6 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
break;
}
final int updatedButtonsMask = buttonsMask;
// We don't handle ACTION_BUTTON_PRESS and ACTION_BUTTON_RELEASE events as they typically
// follow ACTION_DOWN and ACTION_UP events. As such, handling them would result in duplicate
// stream of events to the engine.
@ -607,11 +610,8 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
case MotionEvent.ACTION_HOVER_MOVE:
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_SCROLL: {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.dispatchMouseEvent(eventAction, updatedButtonsMask, x, y, deltaX, deltaY, doubleClick, sourceMouseRelative, pressure, tiltX, tiltY));
} else {
GodotLib.dispatchMouseEvent(eventAction, updatedButtonsMask, x, y, deltaX, deltaY, doubleClick, sourceMouseRelative, pressure, tiltX, tiltY);
}
runnable.setMouseEvent(eventAction, buttonsMask, x, y, deltaX, deltaY, doubleClick, sourceMouseRelative, pressure, tiltX, tiltY);
dispatchInputEventRunnable(runnable);
return true;
}
}
@ -627,22 +627,14 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
}
boolean handleTouchEvent(final MotionEvent event, int eventActionOverride, boolean doubleTap) {
final int pointerCount = event.getPointerCount();
if (pointerCount == 0) {
if (event.getPointerCount() == 0) {
return true;
}
final float[] positions = new float[pointerCount * 6]; // pointerId1, x1, y1, pressure1, tiltX1, tiltY1, pointerId2, etc...
for (int i = 0; i < pointerCount; i++) {
positions[i * 6 + 0] = event.getPointerId(i);
positions[i * 6 + 1] = event.getX(i);
positions[i * 6 + 2] = event.getY(i);
positions[i * 6 + 3] = event.getPressure(i);
positions[i * 6 + 4] = getEventTiltX(event);
positions[i * 6 + 5] = getEventTiltY(event);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return false;
}
final int actionPointerId = event.getPointerId(event.getActionIndex());
switch (eventActionOverride) {
case MotionEvent.ACTION_DOWN:
@ -651,11 +643,8 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_POINTER_UP:
case MotionEvent.ACTION_POINTER_DOWN: {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.dispatchTouchEvent(eventActionOverride, actionPointerId, pointerCount, positions, doubleTap));
} else {
GodotLib.dispatchTouchEvent(eventActionOverride, actionPointerId, pointerCount, positions, doubleTap);
}
runnable.setTouchEvent(event, eventActionOverride, doubleTap);
dispatchInputEventRunnable(runnable);
return true;
}
}
@ -663,58 +652,128 @@ public class GodotInputHandler implements InputManager.InputDeviceListener {
}
void handleMagnifyEvent(float x, float y, float factor) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.magnify(x, y, factor));
} else {
GodotLib.magnify(x, y, factor);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setMagnifyEvent(x, y, factor);
dispatchInputEventRunnable(runnable);
}
void handlePanEvent(float x, float y, float deltaX, float deltaY) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.pan(x, y, deltaX, deltaY));
} else {
GodotLib.pan(x, y, deltaX, deltaY);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setPanEvent(x, y, deltaX, deltaY);
dispatchInputEventRunnable(runnable);
}
private void handleJoystickButtonEvent(int device, int button, boolean pressed) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.joybutton(device, button, pressed));
} else {
GodotLib.joybutton(device, button, pressed);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setJoystickButtonEvent(device, button, pressed);
dispatchInputEventRunnable(runnable);
}
private void handleJoystickAxisEvent(int device, int axis, float value) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.joyaxis(device, axis, value));
} else {
GodotLib.joyaxis(device, axis, value);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setJoystickAxisEvent(device, axis, value);
dispatchInputEventRunnable(runnable);
}
private void handleJoystickHatEvent(int device, int hatX, int hatY) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.joyhat(device, hatX, hatY));
} else {
GodotLib.joyhat(device, hatX, hatY);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setJoystickHatEvent(device, hatX, hatY);
dispatchInputEventRunnable(runnable);
}
private void handleJoystickConnectionChangedEvent(int device, boolean connected, String name) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.joyconnectionchanged(device, connected, name));
} else {
GodotLib.joyconnectionchanged(device, connected, name);
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setJoystickConnectionChangedEvent(device, connected, name);
dispatchInputEventRunnable(runnable);
}
void handleKeyEvent(int physicalKeycode, int unicode, int keyLabel, boolean pressed, boolean echo) {
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
runnable.setKeyEvent(physicalKeycode, unicode, keyLabel, pressed, echo);
dispatchInputEventRunnable(runnable);
}
private void dispatchInputEventRunnable(@NonNull InputEventRunnable runnable) {
if (shouldDispatchInputToRenderThread()) {
mRenderView.queueOnRenderThread(() -> GodotLib.key(physicalKeycode, unicode, keyLabel, pressed, echo));
godot.runOnRenderThread(runnable);
} else {
GodotLib.key(physicalKeycode, unicode, keyLabel, pressed, echo);
runnable.run();
}
}
@Override
public void onSensorChanged(SensorEvent event) {
final float[] values = event.values;
if (values == null || values.length != 3) {
return;
}
InputEventRunnable runnable = InputEventRunnable.obtain();
if (runnable == null) {
return;
}
float rotatedValue0 = 0f;
float rotatedValue1 = 0f;
float rotatedValue2 = 0f;
switch (windowManager.getDefaultDisplay().getRotation()) {
case Surface.ROTATION_0:
rotatedValue0 = values[0];
rotatedValue1 = values[1];
rotatedValue2 = values[2];
break;
case Surface.ROTATION_90:
rotatedValue0 = -values[1];
rotatedValue1 = values[0];
rotatedValue2 = values[2];
break;
case Surface.ROTATION_180:
rotatedValue0 = -values[0];
rotatedValue1 = -values[1];
rotatedValue2 = values[2];
break;
case Surface.ROTATION_270:
rotatedValue0 = values[1];
rotatedValue1 = -values[0];
rotatedValue2 = values[2];
break;
}
runnable.setSensorEvent(event.sensor.getType(), rotatedValue0, rotatedValue1, rotatedValue2);
godot.runOnRenderThread(runnable);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {}
}

View file

@ -0,0 +1,353 @@
/**************************************************************************/
/* InputEventRunnable.java */
/**************************************************************************/
/* 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.godot.input;
import org.godotengine.godot.GodotLib;
import android.hardware.Sensor;
import android.util.Log;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Pools;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Used to dispatch input events.
*
* This is a specialized version of @{@link Runnable} which allows to allocate a finite pool of
* objects for input events dispatching, thus avoid the creation (and garbage collection) of
* spurious @{@link Runnable} objects.
*/
final class InputEventRunnable implements Runnable {
private static final String TAG = InputEventRunnable.class.getSimpleName();
private static final int MAX_TOUCH_POINTER_COUNT = 10; // assuming 10 fingers as max supported concurrent touch pointers
private static final Pools.Pool<InputEventRunnable> POOL = new Pools.Pool<>() {
private static final int MAX_POOL_SIZE = 120 * 10; // up to 120Hz input events rate for up to 5 secs (ANR limit) * 2
private final ArrayBlockingQueue<InputEventRunnable> queue = new ArrayBlockingQueue<>(MAX_POOL_SIZE);
private final AtomicInteger createdCount = new AtomicInteger();
@Nullable
@Override
public InputEventRunnable acquire() {
InputEventRunnable instance = queue.poll();
if (instance == null) {
int creationCount = createdCount.incrementAndGet();
if (creationCount <= MAX_POOL_SIZE) {
instance = new InputEventRunnable(creationCount - 1);
}
}
return instance;
}
@Override
public boolean release(@NonNull InputEventRunnable instance) {
return queue.offer(instance);
}
};
@Nullable
static InputEventRunnable obtain() {
InputEventRunnable runnable = POOL.acquire();
if (runnable == null) {
Log.w(TAG, "Input event pool is at capacity");
}
return runnable;
}
/**
* Used to track when this instance was created and added to the pool. Primarily used for
* debug purposes.
*/
private final int creationRank;
private InputEventRunnable(int creationRank) {
this.creationRank = creationRank;
}
/**
* Set of supported input events.
*/
private enum EventType {
MOUSE,
TOUCH,
MAGNIFY,
PAN,
JOYSTICK_BUTTON,
JOYSTICK_AXIS,
JOYSTICK_HAT,
JOYSTICK_CONNECTION_CHANGED,
KEY,
SENSOR
}
private EventType currentEventType = null;
// common event fields
private float eventX;
private float eventY;
private float eventDeltaX;
private float eventDeltaY;
private boolean eventPressed;
// common touch / mouse fields
private int eventAction;
private boolean doubleTap;
// Mouse event fields and setter
private int buttonsMask;
private boolean sourceMouseRelative;
private float pressure;
private float tiltX;
private float tiltY;
void setMouseEvent(int eventAction, int buttonsMask, float x, float y, float deltaX, float deltaY, boolean doubleClick, boolean sourceMouseRelative, float pressure, float tiltX, float tiltY) {
this.currentEventType = EventType.MOUSE;
this.eventAction = eventAction;
this.buttonsMask = buttonsMask;
this.eventX = x;
this.eventY = y;
this.eventDeltaX = deltaX;
this.eventDeltaY = deltaY;
this.doubleTap = doubleClick;
this.sourceMouseRelative = sourceMouseRelative;
this.pressure = pressure;
this.tiltX = tiltX;
this.tiltY = tiltY;
}
// Touch event fields and setter
private int actionPointerId;
private int pointerCount;
private final float[] positions = new float[MAX_TOUCH_POINTER_COUNT * 6]; // pointerId1, x1, y1, pressure1, tiltX1, tiltY1, pointerId2, etc...
void setTouchEvent(MotionEvent event, int eventAction, boolean doubleTap) {
this.currentEventType = EventType.TOUCH;
this.eventAction = eventAction;
this.doubleTap = doubleTap;
this.actionPointerId = event.getPointerId(event.getActionIndex());
this.pointerCount = Math.min(event.getPointerCount(), MAX_TOUCH_POINTER_COUNT);
for (int i = 0; i < pointerCount; i++) {
positions[i * 6 + 0] = event.getPointerId(i);
positions[i * 6 + 1] = event.getX(i);
positions[i * 6 + 2] = event.getY(i);
positions[i * 6 + 3] = event.getPressure(i);
positions[i * 6 + 4] = GodotInputHandler.getEventTiltX(event);
positions[i * 6 + 5] = GodotInputHandler.getEventTiltY(event);
}
}
// Magnify event fields and setter
private float magnifyFactor;
void setMagnifyEvent(float x, float y, float factor) {
this.currentEventType = EventType.MAGNIFY;
this.eventX = x;
this.eventY = y;
this.magnifyFactor = factor;
}
// Pan event setter
void setPanEvent(float x, float y, float deltaX, float deltaY) {
this.currentEventType = EventType.PAN;
this.eventX = x;
this.eventY = y;
this.eventDeltaX = deltaX;
this.eventDeltaY = deltaY;
}
// common joystick field
private int joystickDevice;
// Joystick button event fields and setter
private int button;
void setJoystickButtonEvent(int device, int button, boolean pressed) {
this.currentEventType = EventType.JOYSTICK_BUTTON;
this.joystickDevice = device;
this.button = button;
this.eventPressed = pressed;
}
// Joystick axis event fields and setter
private int axis;
private float value;
void setJoystickAxisEvent(int device, int axis, float value) {
this.currentEventType = EventType.JOYSTICK_AXIS;
this.joystickDevice = device;
this.axis = axis;
this.value = value;
}
// Joystick hat event fields and setter
private int hatX;
private int hatY;
void setJoystickHatEvent(int device, int hatX, int hatY) {
this.currentEventType = EventType.JOYSTICK_HAT;
this.joystickDevice = device;
this.hatX = hatX;
this.hatY = hatY;
}
// Joystick connection changed event fields and setter
private boolean connected;
private String joystickName;
void setJoystickConnectionChangedEvent(int device, boolean connected, String name) {
this.currentEventType = EventType.JOYSTICK_CONNECTION_CHANGED;
this.joystickDevice = device;
this.connected = connected;
this.joystickName = name;
}
// Key event fields and setter
private int physicalKeycode;
private int unicode;
private int keyLabel;
private boolean echo;
void setKeyEvent(int physicalKeycode, int unicode, int keyLabel, boolean pressed, boolean echo) {
this.currentEventType = EventType.KEY;
this.physicalKeycode = physicalKeycode;
this.unicode = unicode;
this.keyLabel = keyLabel;
this.eventPressed = pressed;
this.echo = echo;
}
// Sensor event fields and setter
private int sensorType;
private float rotatedValue0;
private float rotatedValue1;
private float rotatedValue2;
void setSensorEvent(int sensorType, float rotatedValue0, float rotatedValue1, float rotatedValue2) {
this.currentEventType = EventType.SENSOR;
this.sensorType = sensorType;
this.rotatedValue0 = rotatedValue0;
this.rotatedValue1 = rotatedValue1;
this.rotatedValue2 = rotatedValue2;
}
@Override
public void run() {
try {
if (currentEventType == null) {
Log.w(TAG, "Invalid event type");
return;
}
switch (currentEventType) {
case MOUSE:
GodotLib.dispatchMouseEvent(
eventAction,
buttonsMask,
eventX,
eventY,
eventDeltaX,
eventDeltaY,
doubleTap,
sourceMouseRelative,
pressure,
tiltX,
tiltY);
break;
case TOUCH:
GodotLib.dispatchTouchEvent(
eventAction,
actionPointerId,
pointerCount,
positions,
doubleTap);
break;
case MAGNIFY:
GodotLib.magnify(eventX, eventY, magnifyFactor);
break;
case PAN:
GodotLib.pan(eventX, eventY, eventDeltaX, eventDeltaY);
break;
case JOYSTICK_BUTTON:
GodotLib.joybutton(joystickDevice, button, eventPressed);
break;
case JOYSTICK_AXIS:
GodotLib.joyaxis(joystickDevice, axis, value);
break;
case JOYSTICK_HAT:
GodotLib.joyhat(joystickDevice, hatX, hatY);
break;
case JOYSTICK_CONNECTION_CHANGED:
GodotLib.joyconnectionchanged(joystickDevice, connected, joystickName);
break;
case KEY:
GodotLib.key(physicalKeycode, unicode, keyLabel, eventPressed, echo);
break;
case SENSOR:
switch (sensorType) {
case Sensor.TYPE_ACCELEROMETER:
GodotLib.accelerometer(-rotatedValue0, -rotatedValue1, -rotatedValue2);
break;
case Sensor.TYPE_GRAVITY:
GodotLib.gravity(-rotatedValue0, -rotatedValue1, -rotatedValue2);
break;
case Sensor.TYPE_MAGNETIC_FIELD:
GodotLib.magnetometer(-rotatedValue0, -rotatedValue1, -rotatedValue2);
break;
case Sensor.TYPE_GYROSCOPE:
GodotLib.gyroscope(rotatedValue0, rotatedValue1, rotatedValue2);
break;
}
break;
}
} finally {
recycle();
}
}
/**
* Release the current instance back to the pool
*/
private void recycle() {
currentEventType = null;
POOL.release(this);
}
}

View file

@ -0,0 +1,196 @@
/**************************************************************************/
/* FilePicker.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.godot.io
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import org.godotengine.godot.GodotLib
import org.godotengine.godot.io.file.MediaStoreData
/**
* Utility class for managing file selection and file picker activities.
*
* It provides methods to launch a file picker and handle the result, supporting various file modes,
* including opening files, directories, and saving files.
*/
internal class FilePicker {
companion object {
private const val FILE_PICKER_REQUEST = 1000
private val TAG = FilePicker::class.java.simpleName
// Constants for fileMode values
private const val FILE_MODE_OPEN_FILE = 0
private const val FILE_MODE_OPEN_FILES = 1
private const val FILE_MODE_OPEN_DIR = 2
private const val FILE_MODE_OPEN_ANY = 3
private const val FILE_MODE_SAVE_FILE = 4
/**
* Handles the result from a file picker activity and processes the selected file(s) or directory.
*
* @param context The context from which the file picker was launched.
* @param requestCode The request code used when starting the file picker activity.
* @param resultCode The result code returned by the activity.
* @param data The intent data containing the selected file(s) or directory.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun handleActivityResult(context: Context, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == FILE_PICKER_REQUEST) {
if (resultCode == Activity.RESULT_CANCELED) {
Log.d(TAG, "File picker canceled")
GodotLib.filePickerCallback(false, emptyArray())
return
}
if (resultCode == Activity.RESULT_OK) {
val selectedPaths: MutableList<String> = mutableListOf()
// Handle multiple file selection.
val clipData = data?.clipData
if (clipData != null) {
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
uri?.let {
val filepath = MediaStoreData.getFilePathFromUri(context, uri)
if (filepath != null) {
selectedPaths.add(filepath)
} else {
Log.d(TAG, "null filepath URI: $it")
}
}
}
} else {
val uri: Uri? = data?.data
uri?.let {
val filepath = MediaStoreData.getFilePathFromUri(context, uri)
if (filepath != null) {
selectedPaths.add(filepath)
} else {
Log.d(TAG, "null filepath URI: $it")
}
}
}
if (selectedPaths.isNotEmpty()) {
GodotLib.filePickerCallback(true, selectedPaths.toTypedArray())
} else {
GodotLib.filePickerCallback(false, emptyArray())
}
}
}
}
/**
* Launches a file picker activity with specified settings based on the mode, initial directory,
* file type filters, and other parameters.
*
* @param context The context from which to start the file picker.
* @param activity The activity instance used to initiate the picker. Required for activity results.
* @param currentDirectory The directory path to start the file picker in.
* @param filename The name of the file when using save mode.
* @param fileMode The mode to operate in, specifying open, save, or directory select.
* @param filters Array of MIME types to filter file selection.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun showFilePicker(context: Context, activity: Activity?, currentDirectory: String, filename: String, fileMode: Int, filters: Array<String>) {
val intent = when (fileMode) {
FILE_MODE_OPEN_DIR -> Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
FILE_MODE_SAVE_FILE -> Intent(Intent.ACTION_CREATE_DOCUMENT)
else -> Intent(Intent.ACTION_OPEN_DOCUMENT)
}
val initialDirectory = MediaStoreData.getUriFromDirectoryPath(context, currentDirectory)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && initialDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialDirectory)
} else {
Log.d(TAG, "Error cannot set initial directory")
}
if (fileMode == FILE_MODE_OPEN_FILES) {
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) // Set multi select for FILE_MODE_OPEN_FILES
} else if (fileMode == FILE_MODE_SAVE_FILE) {
intent.putExtra(Intent.EXTRA_TITLE, filename) // Set filename for FILE_MODE_SAVE_FILE
}
// ACTION_OPEN_DOCUMENT_TREE does not support intent type
if (fileMode != FILE_MODE_OPEN_DIR) {
val resolvedFilters = filters.map { resolveMimeType(it) }.distinct()
intent.type = resolvedFilters.firstOrNull { it != "application/octet-stream" } ?: "*/*"
if (resolvedFilters.size > 1) {
intent.putExtra(Intent.EXTRA_MIME_TYPES, resolvedFilters.toTypedArray())
}
intent.addCategory(Intent.CATEGORY_OPENABLE)
}
intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
activity?.startActivityForResult(intent, FILE_PICKER_REQUEST)
}
/**
* Retrieves the MIME type for a given file extension.
*
* @param ext the extension whose MIME type is to be determined.
* @return the MIME type as a string, or "application/octet-stream" if the type is unknown.
*/
private fun resolveMimeType(ext: String): String {
val mimeTypeMap = MimeTypeMap.getSingleton()
var input = ext
// Fix for extensions like "*.txt" or ".txt".
if (ext.contains(".")) {
input = ext.substring(ext.indexOf(".") + 1);
}
// Check if the input is already a valid MIME type.
if (mimeTypeMap.hasMimeType(input)) {
return input
}
val resolvedMimeType = mimeTypeMap.getMimeTypeFromExtension(input)
if (resolvedMimeType != null) {
return resolvedMimeType
}
// Check for wildcard MIME types like "image/*".
if (input.contains("/*")) {
val category = input.substringBefore("/*")
return when (category) {
"image" -> "image/*"
"video" -> "video/*"
"audio" -> "audio/*"
else -> "application/octet-stream"
}
}
// Fallback to a generic MIME type if the input is neither a valid extension nor MIME type.
return "application/octet-stream"
}
}
}

View file

@ -34,11 +34,17 @@ import android.content.Context
import android.os.Build
import android.os.Environment
import java.io.File
import org.godotengine.godot.GodotLib
/**
* Represents the different storage scopes.
*/
internal enum class StorageScope {
/**
* Covers the 'assets' directory
*/
ASSETS,
/**
* Covers internal and external directories accessible to the app without restrictions.
*/
@ -56,6 +62,10 @@ internal enum class StorageScope {
class Identifier(context: Context) {
companion object {
internal const val ASSETS_PREFIX = "assets://"
}
private val internalAppDir: String? = context.filesDir.canonicalPath
private val internalCacheDir: String? = context.cacheDir.canonicalPath
private val externalAppDir: String? = context.getExternalFilesDir(null)?.canonicalPath
@ -63,6 +73,14 @@ internal enum class StorageScope {
private val downloadsSharedDir: String? = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).canonicalPath
private val documentsSharedDir: String? = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS).canonicalPath
/**
* Determine if the given path is accessible.
*/
fun canAccess(path: String?): Boolean {
val storageScope = identifyStorageScope(path)
return storageScope == APP || storageScope == SHARED
}
/**
* Determines which [StorageScope] the given path falls under.
*/
@ -71,9 +89,16 @@ internal enum class StorageScope {
return UNKNOWN
}
val pathFile = File(path)
if (path.startsWith(ASSETS_PREFIX)) {
return ASSETS
}
var pathFile = File(path)
if (!pathFile.isAbsolute) {
return UNKNOWN
pathFile = File(GodotLib.getProjectResourceDir(), path)
if (!pathFile.isAbsolute) {
return UNKNOWN
}
}
// If we have 'All Files Access' permission, we can access all directories without

View file

@ -33,18 +33,30 @@ package org.godotengine.godot.io.directory
import android.content.Context
import android.util.Log
import android.util.SparseArray
import org.godotengine.godot.io.StorageScope
import org.godotengine.godot.io.directory.DirectoryAccessHandler.Companion.INVALID_DIR_ID
import org.godotengine.godot.io.directory.DirectoryAccessHandler.Companion.STARTING_DIR_ID
import org.godotengine.godot.io.file.AssetData
import java.io.File
import java.io.IOException
/**
* Handles directories access within the Android assets directory.
*/
internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.DirectoryAccess {
internal class AssetsDirectoryAccess(private val context: Context) : DirectoryAccessHandler.DirectoryAccess {
companion object {
private val TAG = AssetsDirectoryAccess::class.java.simpleName
internal fun getAssetsPath(originalPath: String): String {
if (originalPath.startsWith(File.separator)) {
return originalPath.substring(File.separator.length)
}
if (originalPath.startsWith(StorageScope.Identifier.ASSETS_PREFIX)) {
return originalPath.substring(StorageScope.Identifier.ASSETS_PREFIX.length)
}
return originalPath
}
}
private data class AssetDir(val path: String, val files: Array<String>, var current: Int = 0)
@ -54,13 +66,6 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.
private var lastDirId = STARTING_DIR_ID
private val dirs = SparseArray<AssetDir>()
private fun getAssetsPath(originalPath: String): String {
if (originalPath.startsWith(File.separatorChar)) {
return originalPath.substring(1)
}
return originalPath
}
override fun hasDirId(dirId: Int) = dirs.indexOfKey(dirId) >= 0
override fun dirOpen(path: String): Int {
@ -68,8 +73,8 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.
try {
val files = assetManager.list(assetsPath) ?: return INVALID_DIR_ID
// Empty directories don't get added to the 'assets' directory, so
// if ad.files.length > 0 ==> path is directory
// if ad.files.length == 0 ==> path is file
// if files.length > 0 ==> path is directory
// if files.length == 0 ==> path is file
if (files.isEmpty()) {
return INVALID_DIR_ID
}
@ -89,8 +94,8 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.
try {
val files = assetManager.list(assetsPath) ?: return false
// Empty directories don't get added to the 'assets' directory, so
// if ad.files.length > 0 ==> path is directory
// if ad.files.length == 0 ==> path is file
// if files.length > 0 ==> path is directory
// if files.length == 0 ==> path is file
return files.isNotEmpty()
} catch (e: IOException) {
Log.e(TAG, "Exception on dirExists", e)
@ -98,19 +103,7 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.
}
}
override fun fileExists(path: String): Boolean {
val assetsPath = getAssetsPath(path)
try {
val files = assetManager.list(assetsPath) ?: return false
// Empty directories don't get added to the 'assets' directory, so
// if ad.files.length > 0 ==> path is directory
// if ad.files.length == 0 ==> path is file
return files.isEmpty()
} catch (e: IOException) {
Log.e(TAG, "Exception on fileExists", e)
return false
}
}
override fun fileExists(path: String) = AssetData.fileExists(context, path)
override fun dirIsDir(dirId: Int): Boolean {
val ad: AssetDir = dirs[dirId]
@ -171,7 +164,7 @@ internal class AssetsDirectoryAccess(context: Context) : DirectoryAccessHandler.
override fun getSpaceLeft() = 0L
override fun rename(from: String, to: String) = false
override fun rename(from: String, to: String) = AssetData.rename(from, to)
override fun remove(filename: String) = false
override fun remove(filename: String) = AssetData.delete(filename)
}

View file

@ -32,7 +32,8 @@ package org.godotengine.godot.io.directory
import android.content.Context
import android.util.Log
import org.godotengine.godot.io.directory.DirectoryAccessHandler.AccessType.ACCESS_FILESYSTEM
import org.godotengine.godot.Godot
import org.godotengine.godot.io.StorageScope
import org.godotengine.godot.io.directory.DirectoryAccessHandler.AccessType.ACCESS_RESOURCES
/**
@ -45,18 +46,82 @@ class DirectoryAccessHandler(context: Context) {
internal const val INVALID_DIR_ID = -1
internal const val STARTING_DIR_ID = 1
private fun getAccessTypeFromNative(accessType: Int): AccessType? {
return when (accessType) {
ACCESS_RESOURCES.nativeValue -> ACCESS_RESOURCES
ACCESS_FILESYSTEM.nativeValue -> ACCESS_FILESYSTEM
else -> null
}
}
}
private enum class AccessType(val nativeValue: Int) {
ACCESS_RESOURCES(0), ACCESS_FILESYSTEM(2)
ACCESS_RESOURCES(0),
/**
* Maps to [ACCESS_FILESYSTEM]
*/
ACCESS_USERDATA(1),
ACCESS_FILESYSTEM(2);
fun generateDirAccessId(dirId: Int) = (dirId * DIR_ACCESS_ID_MULTIPLIER) + nativeValue
companion object {
const val DIR_ACCESS_ID_MULTIPLIER = 10
fun fromDirAccessId(dirAccessId: Int): Pair<AccessType?, Int> {
val nativeValue = dirAccessId % DIR_ACCESS_ID_MULTIPLIER
val dirId = dirAccessId / DIR_ACCESS_ID_MULTIPLIER
return Pair(fromNative(nativeValue), dirId)
}
private fun fromNative(nativeAccessType: Int): AccessType? {
for (accessType in entries) {
if (accessType.nativeValue == nativeAccessType) {
return accessType
}
}
return null
}
fun fromNative(nativeAccessType: Int, storageScope: StorageScope? = null): AccessType? {
val accessType = fromNative(nativeAccessType)
if (accessType == null) {
Log.w(TAG, "Unsupported access type $nativeAccessType")
return null
}
// 'Resources' access type takes precedence as it is simple to handle:
// if we receive a 'Resources' access type and this is a template build,
// we provide a 'Resources' directory handler.
// If this is an editor build, 'Resources' refers to the opened project resources
// and so we provide a 'Filesystem' directory handler.
if (accessType == ACCESS_RESOURCES) {
return if (Godot.isEditorBuild()) {
ACCESS_FILESYSTEM
} else {
ACCESS_RESOURCES
}
} else {
// We've received a 'Filesystem' or 'Userdata' access type. On Android, this
// may refer to:
// - assets directory (path has 'assets:/' prefix)
// - app directories
// - device shared directories
// As such we check the storage scope (if available) to figure what type of
// directory handler to provide
if (storageScope != null) {
val accessTypeFromStorageScope = when (storageScope) {
StorageScope.ASSETS -> ACCESS_RESOURCES
StorageScope.APP, StorageScope.SHARED -> ACCESS_FILESYSTEM
StorageScope.UNKNOWN -> null
}
if (accessTypeFromStorageScope != null) {
return accessTypeFromStorageScope
}
}
// If we're not able to infer the type of directory handler from the storage
// scope, we fall-back to the 'Filesystem' directory handler as it's the default
// for the 'Filesystem' access type.
// Note that ACCESS_USERDATA also maps to ACCESS_FILESYSTEM
return ACCESS_FILESYSTEM
}
}
}
}
internal interface DirectoryAccess {
@ -76,8 +141,10 @@ class DirectoryAccessHandler(context: Context) {
fun remove(filename: String): Boolean
}
private val storageScopeIdentifier = StorageScope.Identifier(context)
private val assetsDirAccess = AssetsDirectoryAccess(context)
private val fileSystemDirAccess = FilesystemDirectoryAccess(context)
private val fileSystemDirAccess = FilesystemDirectoryAccess(context, storageScopeIdentifier)
fun assetsFileExists(assetsPath: String) = assetsDirAccess.fileExists(assetsPath)
fun filesystemFileExists(path: String) = fileSystemDirAccess.fileExists(path)
@ -85,24 +152,32 @@ class DirectoryAccessHandler(context: Context) {
private fun hasDirId(accessType: AccessType, dirId: Int): Boolean {
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.hasDirId(dirId)
ACCESS_FILESYSTEM -> fileSystemDirAccess.hasDirId(dirId)
else -> fileSystemDirAccess.hasDirId(dirId)
}
}
fun dirOpen(nativeAccessType: Int, path: String?): Int {
val accessType = getAccessTypeFromNative(nativeAccessType)
if (path == null || accessType == null) {
if (path == null) {
return INVALID_DIR_ID
}
return when (accessType) {
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return INVALID_DIR_ID
val dirId = when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.dirOpen(path)
ACCESS_FILESYSTEM -> fileSystemDirAccess.dirOpen(path)
else -> fileSystemDirAccess.dirOpen(path)
}
if (dirId == INVALID_DIR_ID) {
return INVALID_DIR_ID
}
val dirAccessId = accessType.generateDirAccessId(dirId)
return dirAccessId
}
fun dirNext(nativeAccessType: Int, dirId: Int): String {
val accessType = getAccessTypeFromNative(nativeAccessType)
fun dirNext(dirAccessId: Int): String {
val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId)
if (accessType == null || !hasDirId(accessType, dirId)) {
Log.w(TAG, "dirNext: Invalid dir id: $dirId")
return ""
@ -110,12 +185,12 @@ class DirectoryAccessHandler(context: Context) {
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.dirNext(dirId)
ACCESS_FILESYSTEM -> fileSystemDirAccess.dirNext(dirId)
else -> fileSystemDirAccess.dirNext(dirId)
}
}
fun dirClose(nativeAccessType: Int, dirId: Int) {
val accessType = getAccessTypeFromNative(nativeAccessType)
fun dirClose(dirAccessId: Int) {
val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId)
if (accessType == null || !hasDirId(accessType, dirId)) {
Log.w(TAG, "dirClose: Invalid dir id: $dirId")
return
@ -123,12 +198,12 @@ class DirectoryAccessHandler(context: Context) {
when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.dirClose(dirId)
ACCESS_FILESYSTEM -> fileSystemDirAccess.dirClose(dirId)
else -> fileSystemDirAccess.dirClose(dirId)
}
}
fun dirIsDir(nativeAccessType: Int, dirId: Int): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType)
fun dirIsDir(dirAccessId: Int): Boolean {
val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId)
if (accessType == null || !hasDirId(accessType, dirId)) {
Log.w(TAG, "dirIsDir: Invalid dir id: $dirId")
return false
@ -136,91 +211,106 @@ class DirectoryAccessHandler(context: Context) {
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.dirIsDir(dirId)
ACCESS_FILESYSTEM -> fileSystemDirAccess.dirIsDir(dirId)
else -> fileSystemDirAccess.dirIsDir(dirId)
}
}
fun isCurrentHidden(nativeAccessType: Int, dirId: Int): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType)
fun isCurrentHidden(dirAccessId: Int): Boolean {
val (accessType, dirId) = AccessType.fromDirAccessId(dirAccessId)
if (accessType == null || !hasDirId(accessType, dirId)) {
return false
}
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.isCurrentHidden(dirId)
ACCESS_FILESYSTEM -> fileSystemDirAccess.isCurrentHidden(dirId)
else -> fileSystemDirAccess.isCurrentHidden(dirId)
}
}
fun dirExists(nativeAccessType: Int, path: String?): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType)
if (path == null || accessType == null) {
if (path == null) {
return false
}
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.dirExists(path)
ACCESS_FILESYSTEM -> fileSystemDirAccess.dirExists(path)
else -> fileSystemDirAccess.dirExists(path)
}
}
fun fileExists(nativeAccessType: Int, path: String?): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType)
if (path == null || accessType == null) {
if (path == null) {
return false
}
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.fileExists(path)
ACCESS_FILESYSTEM -> fileSystemDirAccess.fileExists(path)
else -> fileSystemDirAccess.fileExists(path)
}
}
fun getDriveCount(nativeAccessType: Int): Int {
val accessType = getAccessTypeFromNative(nativeAccessType) ?: return 0
val accessType = AccessType.fromNative(nativeAccessType) ?: return 0
return when(accessType) {
ACCESS_RESOURCES -> assetsDirAccess.getDriveCount()
ACCESS_FILESYSTEM -> fileSystemDirAccess.getDriveCount()
else -> fileSystemDirAccess.getDriveCount()
}
}
fun getDrive(nativeAccessType: Int, drive: Int): String {
val accessType = getAccessTypeFromNative(nativeAccessType) ?: return ""
val accessType = AccessType.fromNative(nativeAccessType) ?: return ""
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.getDrive(drive)
ACCESS_FILESYSTEM -> fileSystemDirAccess.getDrive(drive)
else -> fileSystemDirAccess.getDrive(drive)
}
}
fun makeDir(nativeAccessType: Int, dir: String): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType) ?: return false
fun makeDir(nativeAccessType: Int, dir: String?): Boolean {
if (dir == null) {
return false
}
val storageScope = storageScopeIdentifier.identifyStorageScope(dir)
val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.makeDir(dir)
ACCESS_FILESYSTEM -> fileSystemDirAccess.makeDir(dir)
else -> fileSystemDirAccess.makeDir(dir)
}
}
fun getSpaceLeft(nativeAccessType: Int): Long {
val accessType = getAccessTypeFromNative(nativeAccessType) ?: return 0L
val accessType = AccessType.fromNative(nativeAccessType) ?: return 0L
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.getSpaceLeft()
ACCESS_FILESYSTEM -> fileSystemDirAccess.getSpaceLeft()
else -> fileSystemDirAccess.getSpaceLeft()
}
}
fun rename(nativeAccessType: Int, from: String, to: String): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType) ?: return false
val accessType = AccessType.fromNative(nativeAccessType) ?: return false
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.rename(from, to)
ACCESS_FILESYSTEM -> fileSystemDirAccess.rename(from, to)
else -> fileSystemDirAccess.rename(from, to)
}
}
fun remove(nativeAccessType: Int, filename: String): Boolean {
val accessType = getAccessTypeFromNative(nativeAccessType) ?: return false
fun remove(nativeAccessType: Int, filename: String?): Boolean {
if (filename == null) {
return false
}
val storageScope = storageScopeIdentifier.identifyStorageScope(filename)
val accessType = AccessType.fromNative(nativeAccessType, storageScope) ?: return false
return when (accessType) {
ACCESS_RESOURCES -> assetsDirAccess.remove(filename)
ACCESS_FILESYSTEM -> fileSystemDirAccess.remove(filename)
else -> fileSystemDirAccess.remove(filename)
}
}

View file

@ -45,7 +45,7 @@ import java.io.File
/**
* Handles directories access with the internal and external filesystem.
*/
internal class FilesystemDirectoryAccess(private val context: Context):
internal class FilesystemDirectoryAccess(private val context: Context, private val storageScopeIdentifier: StorageScope.Identifier):
DirectoryAccessHandler.DirectoryAccess {
companion object {
@ -54,7 +54,6 @@ internal class FilesystemDirectoryAccess(private val context: Context):
private data class DirData(val dirFile: File, val files: Array<File>, var current: Int = 0)
private val storageScopeIdentifier = StorageScope.Identifier(context)
private val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
private var lastDirId = STARTING_DIR_ID
private val dirs = SparseArray<DirData>()
@ -63,7 +62,8 @@ internal class FilesystemDirectoryAccess(private val context: Context):
// Directory access is available for shared storage on Android 11+
// On Android 10, access is also available as long as the `requestLegacyExternalStorage`
// tag is available.
return storageScopeIdentifier.identifyStorageScope(path) != StorageScope.UNKNOWN
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
return storageScope != StorageScope.UNKNOWN && storageScope != StorageScope.ASSETS
}
override fun hasDirId(dirId: Int) = dirs.indexOfKey(dirId) >= 0

View file

@ -0,0 +1,152 @@
/**************************************************************************/
/* AssetData.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.godot.io.file
import android.content.Context
import android.content.res.AssetManager
import android.util.Log
import org.godotengine.godot.error.Error
import org.godotengine.godot.io.directory.AssetsDirectoryAccess
import java.io.IOException
import java.io.InputStream
import java.lang.UnsupportedOperationException
import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.channels.ReadableByteChannel
/**
* Implementation of the [DataAccess] which handles access and interaction with files in the
* 'assets' directory
*/
internal class AssetData(context: Context, private val filePath: String, accessFlag: FileAccessFlags) : DataAccess() {
companion object {
private val TAG = AssetData::class.java.simpleName
fun fileExists(context: Context, path: String): Boolean {
val assetsPath = AssetsDirectoryAccess.getAssetsPath(path)
try {
val files = context.assets.list(assetsPath) ?: return false
// Empty directories don't get added to the 'assets' directory, so
// if files.length > 0 ==> path is directory
// if files.length == 0 ==> path is file
return files.isEmpty()
} catch (e: IOException) {
Log.e(TAG, "Exception on fileExists", e)
return false
}
}
fun fileLastModified(path: String) = 0L
fun delete(path: String) = false
fun rename(from: String, to: String) = false
}
private val inputStream: InputStream
internal val readChannel: ReadableByteChannel
private var position = 0L
private val length: Long
init {
if (accessFlag == FileAccessFlags.WRITE) {
throw UnsupportedOperationException("Writing to the 'assets' directory is not supported")
}
val assetsPath = AssetsDirectoryAccess.getAssetsPath(filePath)
inputStream = context.assets.open(assetsPath, AssetManager.ACCESS_BUFFER)
readChannel = Channels.newChannel(inputStream)
length = inputStream.available().toLong()
}
override fun close() {
try {
inputStream.close()
} catch (e: IOException) {
Log.w(TAG, "Exception when closing file $filePath.", e)
}
}
override fun flush() {
Log.w(TAG, "flush() is not supported.")
}
override fun seek(position: Long) {
try {
inputStream.skip(position)
this.position = position
if (this.position > length) {
this.position = length
endOfFile = true
} else {
endOfFile = false
}
} catch(e: IOException) {
Log.w(TAG, "Exception when seeking file $filePath.", e)
}
}
override fun resize(length: Long): Error {
Log.w(TAG, "resize() is not supported.")
return Error.ERR_UNAVAILABLE
}
override fun position() = position
override fun size() = length
override fun read(buffer: ByteBuffer): Int {
return try {
val readBytes = readChannel.read(buffer)
if (readBytes == -1) {
endOfFile = true
0
} else {
position += readBytes
endOfFile = position() >= size()
readBytes
}
} catch (e: IOException) {
Log.w(TAG, "Exception while reading from $filePath.", e)
0
}
}
override fun write(buffer: ByteBuffer): Boolean {
Log.w(TAG, "write() is not supported.")
return false
}
}

View file

@ -33,12 +33,17 @@ package org.godotengine.godot.io.file
import android.content.Context
import android.os.Build
import android.util.Log
import org.godotengine.godot.error.Error
import org.godotengine.godot.io.StorageScope
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.channels.ClosedChannelException
import java.nio.channels.FileChannel
import java.nio.channels.NonWritableChannelException
import kotlin.jvm.Throws
import kotlin.math.max
/**
@ -47,11 +52,37 @@ import kotlin.math.max
* Its derived instances provide concrete implementations to handle regular file access, as well
* as file access through the media store API on versions of Android were scoped storage is enabled.
*/
internal abstract class DataAccess(private val filePath: String) {
internal abstract class DataAccess {
companion object {
private val TAG = DataAccess::class.java.simpleName
@Throws(java.lang.Exception::class, FileNotFoundException::class)
fun getInputStream(storageScope: StorageScope, context: Context, filePath: String): InputStream? {
return when(storageScope) {
StorageScope.ASSETS -> {
val assetData = AssetData(context, filePath, FileAccessFlags.READ)
Channels.newInputStream(assetData.readChannel)
}
StorageScope.APP -> {
val fileData = FileData(filePath, FileAccessFlags.READ)
Channels.newInputStream(fileData.fileChannel)
}
StorageScope.SHARED -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val mediaStoreData = MediaStoreData(context, filePath, FileAccessFlags.READ)
Channels.newInputStream(mediaStoreData.fileChannel)
} else {
null
}
}
StorageScope.UNKNOWN -> null
}
}
@Throws(java.lang.Exception::class, FileNotFoundException::class)
fun generateDataAccess(
storageScope: StorageScope,
context: Context,
@ -61,6 +92,8 @@ internal abstract class DataAccess(private val filePath: String) {
return when (storageScope) {
StorageScope.APP -> FileData(filePath, accessFlag)
StorageScope.ASSETS -> AssetData(context, filePath, accessFlag)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData(context, filePath, accessFlag)
} else {
@ -74,7 +107,13 @@ internal abstract class DataAccess(private val filePath: String) {
fun fileExists(storageScope: StorageScope, context: Context, path: String): Boolean {
return when(storageScope) {
StorageScope.APP -> FileData.fileExists(path)
StorageScope.SHARED -> MediaStoreData.fileExists(context, path)
StorageScope.ASSETS -> AssetData.fileExists(context, path)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData.fileExists(context, path)
} else {
false
}
StorageScope.UNKNOWN -> false
}
}
@ -82,7 +121,13 @@ internal abstract class DataAccess(private val filePath: String) {
fun fileLastModified(storageScope: StorageScope, context: Context, path: String): Long {
return when(storageScope) {
StorageScope.APP -> FileData.fileLastModified(path)
StorageScope.SHARED -> MediaStoreData.fileLastModified(context, path)
StorageScope.ASSETS -> AssetData.fileLastModified(path)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData.fileLastModified(context, path)
} else {
0L
}
StorageScope.UNKNOWN -> 0L
}
}
@ -90,7 +135,13 @@ internal abstract class DataAccess(private val filePath: String) {
fun removeFile(storageScope: StorageScope, context: Context, path: String): Boolean {
return when(storageScope) {
StorageScope.APP -> FileData.delete(path)
StorageScope.SHARED -> MediaStoreData.delete(context, path)
StorageScope.ASSETS -> AssetData.delete(path)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData.delete(context, path)
} else {
false
}
StorageScope.UNKNOWN -> false
}
}
@ -98,103 +149,122 @@ internal abstract class DataAccess(private val filePath: String) {
fun renameFile(storageScope: StorageScope, context: Context, from: String, to: String): Boolean {
return when(storageScope) {
StorageScope.APP -> FileData.rename(from, to)
StorageScope.SHARED -> MediaStoreData.rename(context, from, to)
StorageScope.ASSETS -> AssetData.rename(from, to)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData.rename(context, from, to)
} else {
false
}
StorageScope.UNKNOWN -> false
}
}
}
protected abstract val fileChannel: FileChannel
internal var endOfFile = false
fun close() {
try {
fileChannel.close()
} catch (e: IOException) {
Log.w(TAG, "Exception when closing file $filePath.", e)
}
}
fun flush() {
try {
fileChannel.force(false)
} catch (e: IOException) {
Log.w(TAG, "Exception when flushing file $filePath.", e)
}
}
fun seek(position: Long) {
try {
fileChannel.position(position)
endOfFile = position >= fileChannel.size()
} catch (e: Exception) {
Log.w(TAG, "Exception when seeking file $filePath.", e)
}
}
abstract fun close()
abstract fun flush()
abstract fun seek(position: Long)
abstract fun resize(length: Long): Error
abstract fun position(): Long
abstract fun size(): Long
abstract fun read(buffer: ByteBuffer): Int
abstract fun write(buffer: ByteBuffer): Boolean
fun seekFromEnd(positionFromEnd: Long) {
val positionFromBeginning = max(0, size() - positionFromEnd)
seek(positionFromBeginning)
}
fun resize(length: Long): Int {
return try {
fileChannel.truncate(length)
FileErrors.OK.nativeValue
} catch (e: NonWritableChannelException) {
FileErrors.FILE_CANT_OPEN.nativeValue
} catch (e: ClosedChannelException) {
FileErrors.FILE_CANT_OPEN.nativeValue
} catch (e: IllegalArgumentException) {
FileErrors.INVALID_PARAMETER.nativeValue
} catch (e: IOException) {
FileErrors.FAILED.nativeValue
}
}
abstract class FileChannelDataAccess(private val filePath: String) : DataAccess() {
internal abstract val fileChannel: FileChannel
fun position(): Long {
return try {
fileChannel.position()
override fun close() {
try {
fileChannel.close()
} catch (e: IOException) {
Log.w(TAG, "Exception when closing file $filePath.", e)
}
}
override fun flush() {
try {
fileChannel.force(false)
} catch (e: IOException) {
Log.w(TAG, "Exception when flushing file $filePath.", e)
}
}
override fun seek(position: Long) {
try {
fileChannel.position(position)
endOfFile = position >= fileChannel.size()
} catch (e: Exception) {
Log.w(TAG, "Exception when seeking file $filePath.", e)
}
}
override fun resize(length: Long): Error {
return try {
fileChannel.truncate(length)
Error.OK
} catch (e: NonWritableChannelException) {
Error.ERR_FILE_CANT_OPEN
} catch (e: ClosedChannelException) {
Error.ERR_FILE_CANT_OPEN
} catch (e: IllegalArgumentException) {
Error.ERR_INVALID_PARAMETER
} catch (e: IOException) {
Error.FAILED
}
}
override fun position(): Long {
return try {
fileChannel.position()
} catch (e: IOException) {
Log.w(
TAG,
"Exception when retrieving position for file $filePath.",
e
)
0L
}
}
override fun size() = try {
fileChannel.size()
} catch (e: IOException) {
Log.w(
TAG,
"Exception when retrieving position for file $filePath.",
e
)
Log.w(TAG, "Exception when retrieving size for file $filePath.", e)
0L
}
}
fun size() = try {
fileChannel.size()
} catch (e: IOException) {
Log.w(TAG, "Exception when retrieving size for file $filePath.", e)
0L
}
fun read(buffer: ByteBuffer): Int {
return try {
val readBytes = fileChannel.read(buffer)
endOfFile = readBytes == -1 || (fileChannel.position() >= fileChannel.size())
if (readBytes == -1) {
override fun read(buffer: ByteBuffer): Int {
return try {
val readBytes = fileChannel.read(buffer)
endOfFile = readBytes == -1 || (fileChannel.position() >= fileChannel.size())
if (readBytes == -1) {
0
} else {
readBytes
}
} catch (e: IOException) {
Log.w(TAG, "Exception while reading from file $filePath.", e)
0
} else {
readBytes
}
} catch (e: IOException) {
Log.w(TAG, "Exception while reading from file $filePath.", e)
0
}
}
fun write(buffer: ByteBuffer) {
try {
val writtenBytes = fileChannel.write(buffer)
if (writtenBytes > 0) {
endOfFile = false
override fun write(buffer: ByteBuffer): Boolean {
try {
val writtenBytes = fileChannel.write(buffer)
if (writtenBytes > 0) {
endOfFile = false
}
return true
} catch (e: IOException) {
Log.w(TAG, "Exception while writing to file $filePath.", e)
return false
}
} catch (e: IOException) {
Log.w(TAG, "Exception while writing to file $filePath.", e)
}
}
}

View file

@ -76,7 +76,7 @@ internal enum class FileAccessFlags(val nativeValue: Int) {
companion object {
fun fromNativeModeFlags(modeFlag: Int): FileAccessFlags? {
for (flag in values()) {
for (flag in entries) {
if (flag.nativeValue == modeFlag) {
return flag
}

View file

@ -33,8 +33,11 @@ package org.godotengine.godot.io.file
import android.content.Context
import android.util.Log
import android.util.SparseArray
import org.godotengine.godot.error.Error
import org.godotengine.godot.io.StorageScope
import java.io.FileNotFoundException
import java.io.InputStream
import java.lang.UnsupportedOperationException
import java.nio.ByteBuffer
/**
@ -45,8 +48,20 @@ class FileAccessHandler(val context: Context) {
companion object {
private val TAG = FileAccessHandler::class.java.simpleName
internal const val INVALID_FILE_ID = 0
private const val INVALID_FILE_ID = 0
private const val STARTING_FILE_ID = 1
private val FILE_OPEN_FAILED = Pair(Error.FAILED, INVALID_FILE_ID)
internal fun getInputStream(context: Context, storageScopeIdentifier: StorageScope.Identifier, path: String?): InputStream? {
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
return try {
path?.let {
DataAccess.getInputStream(storageScope, context, path)
}
} catch (e: Exception) {
null
}
}
internal fun fileExists(context: Context, storageScopeIdentifier: StorageScope.Identifier, path: String?): Boolean {
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
@ -92,35 +107,55 @@ class FileAccessHandler(val context: Context) {
}
}
private val storageScopeIdentifier = StorageScope.Identifier(context)
internal val storageScopeIdentifier = StorageScope.Identifier(context)
private val files = SparseArray<DataAccess>()
private var lastFileId = STARTING_FILE_ID
private fun hasFileId(fileId: Int) = files.indexOfKey(fileId) >= 0
fun fileOpen(path: String?, modeFlags: Int): Int {
val accessFlag = FileAccessFlags.fromNativeModeFlags(modeFlags) ?: return INVALID_FILE_ID
return fileOpen(path, accessFlag)
fun canAccess(filePath: String?): Boolean {
return storageScopeIdentifier.canAccess(filePath)
}
internal fun fileOpen(path: String?, accessFlag: FileAccessFlags): Int {
/**
* Returns a positive (> 0) file id when the operation succeeds.
* Otherwise, returns a negative value of [Error].
*/
fun fileOpen(path: String?, modeFlags: Int): Int {
val (fileError, fileId) = fileOpen(path, FileAccessFlags.fromNativeModeFlags(modeFlags))
return if (fileError == Error.OK) {
fileId
} else {
// Return the negative of the [Error#toNativeValue()] value to differentiate from the
// positive file id.
-fileError.toNativeValue()
}
}
internal fun fileOpen(path: String?, accessFlag: FileAccessFlags?): Pair<Error, Int> {
if (accessFlag == null) {
return FILE_OPEN_FAILED
}
val storageScope = storageScopeIdentifier.identifyStorageScope(path)
if (storageScope == StorageScope.UNKNOWN) {
return INVALID_FILE_ID
return FILE_OPEN_FAILED
}
return try {
path?.let {
val dataAccess = DataAccess.generateDataAccess(storageScope, context, it, accessFlag) ?: return INVALID_FILE_ID
val dataAccess = DataAccess.generateDataAccess(storageScope, context, it, accessFlag) ?: return FILE_OPEN_FAILED
files.put(++lastFileId, dataAccess)
lastFileId
} ?: INVALID_FILE_ID
Pair(Error.OK, lastFileId)
} ?: FILE_OPEN_FAILED
} catch (e: FileNotFoundException) {
FileErrors.FILE_NOT_FOUND.nativeValue
Pair(Error.ERR_FILE_NOT_FOUND, INVALID_FILE_ID)
} catch (e: UnsupportedOperationException) {
Pair(Error.ERR_UNAVAILABLE, INVALID_FILE_ID)
} catch (e: Exception) {
Log.w(TAG, "Error while opening $path", e)
INVALID_FILE_ID
FILE_OPEN_FAILED
}
}
@ -156,12 +191,12 @@ class FileAccessHandler(val context: Context) {
return files[fileId].read(byteBuffer)
}
fun fileWrite(fileId: Int, byteBuffer: ByteBuffer?) {
fun fileWrite(fileId: Int, byteBuffer: ByteBuffer?): Boolean {
if (!hasFileId(fileId) || byteBuffer == null) {
return
return false
}
files[fileId].write(byteBuffer)
return files[fileId].write(byteBuffer)
}
fun fileFlush(fileId: Int) {
@ -172,6 +207,10 @@ class FileAccessHandler(val context: Context) {
files[fileId].flush()
}
fun getInputStream(path: String?) = Companion.getInputStream(context, storageScopeIdentifier, path)
fun renameFile(from: String, to: String) = Companion.renameFile(context, storageScopeIdentifier, from, to)
fun fileExists(path: String?) = Companion.fileExists(context, storageScopeIdentifier, path)
fun fileLastModified(filepath: String?): Long {
@ -191,10 +230,10 @@ class FileAccessHandler(val context: Context) {
fun fileResize(fileId: Int, length: Long): Int {
if (!hasFileId(fileId)) {
return FileErrors.FAILED.nativeValue
return Error.FAILED.toNativeValue()
}
return files[fileId].resize(length)
return files[fileId].resize(length).toNativeValue()
}
fun fileGetPosition(fileId: Int): Long {

View file

@ -38,7 +38,7 @@ import java.nio.channels.FileChannel
/**
* Implementation of [DataAccess] which handles regular (not scoped) file access and interactions.
*/
internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAccess(filePath) {
internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAccess.FileChannelDataAccess(filePath) {
companion object {
private val TAG = FileData::class.java.simpleName
@ -53,7 +53,7 @@ internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAcc
fun fileLastModified(filepath: String): Long {
return try {
File(filepath).lastModified()
File(filepath).lastModified() / 1000L
} catch (e: SecurityException) {
0L
}
@ -80,10 +80,16 @@ internal class FileData(filePath: String, accessFlag: FileAccessFlags) : DataAcc
override val fileChannel: FileChannel
init {
if (accessFlag == FileAccessFlags.WRITE) {
fileChannel = FileOutputStream(filePath, !accessFlag.shouldTruncate()).channel
fileChannel = if (accessFlag == FileAccessFlags.WRITE) {
// Create parent directory is necessary
val parentDir = File(filePath).parentFile
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs()
}
FileOutputStream(filePath, !accessFlag.shouldTruncate()).channel
} else {
fileChannel = RandomAccessFile(filePath, accessFlag.getMode()).channel
RandomAccessFile(filePath, accessFlag.getMode()).channel
}
if (accessFlag.shouldTruncate()) {

View file

@ -38,6 +38,7 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import androidx.annotation.RequiresApi
import java.io.File
@ -46,13 +47,14 @@ import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.nio.channels.FileChannel
/**
* Implementation of [DataAccess] which handles access and interactions with file and data
* under scoped storage via the MediaStore API.
*/
@RequiresApi(Build.VERSION_CODES.Q)
internal class MediaStoreData(context: Context, filePath: String, accessFlag: FileAccessFlags) :
DataAccess(filePath) {
DataAccess.FileChannelDataAccess(filePath) {
private data class DataItem(
val id: Long,
@ -81,6 +83,10 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
private const val SELECTION_BY_PATH = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ? " +
" AND ${MediaStore.Files.FileColumns.RELATIVE_PATH} = ?"
private const val AUTHORITY_MEDIA_DOCUMENTS = "com.android.providers.media.documents"
private const val AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS = "com.android.externalstorage.documents"
private const val AUTHORITY_DOWNLOADS_DOCUMENTS = "com.android.providers.downloads.documents"
private fun getSelectionByPathArguments(path: String): Array<String> {
return arrayOf(getMediaStoreDisplayName(path), getMediaStoreRelativePath(path))
}
@ -203,7 +209,7 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
}
val dataItem = result[0]
return dataItem.dateModified.toLong()
return dataItem.dateModified.toLong() / 1000L
}
fun rename(context: Context, from: String, to: String): Boolean {
@ -230,6 +236,72 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
)
return updated > 0
}
fun getUriFromDirectoryPath(context: Context, directoryPath: String): Uri? {
if (!directoryExists(directoryPath)) {
return null
}
// Check if the path is under external storage.
val externalStorageRoot = Environment.getExternalStorageDirectory().absolutePath
if (directoryPath.startsWith(externalStorageRoot)) {
val relativePath = directoryPath.replaceFirst(externalStorageRoot, "").trim('/')
val uri = Uri.Builder()
.scheme("content")
.authority(AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS)
.appendPath("document")
.appendPath("primary:$relativePath")
.build()
return uri
}
return null
}
fun getFilePathFromUri(context: Context, uri: Uri): String? {
// Converts content uri to filepath.
val id = getIdFromUri(uri) ?: return null
if (uri.authority == AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS) {
val split = id.split(":")
val fileName = split.last()
val relativePath = split.dropLast(1).joinToString("/")
val fullPath = File(Environment.getExternalStorageDirectory(), "$relativePath/$fileName").absolutePath
return fullPath
} else {
val id = id.toLongOrNull() ?: return null
val dataItems = queryById(context, id)
return if (dataItems.isNotEmpty()) {
val dataItem = dataItems[0]
File(Environment.getExternalStorageDirectory(), File(dataItem.relativePath, dataItem.displayName).toString()).absolutePath
} else {
null
}
}
}
private fun getIdFromUri(uri: Uri): String? {
return try {
if (uri.authority == AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS || uri.authority == AUTHORITY_MEDIA_DOCUMENTS || uri.authority == AUTHORITY_DOWNLOADS_DOCUMENTS) {
val documentId = uri.lastPathSegment ?: throw IllegalArgumentException("Invalid URI: $uri")
documentId.substringAfter(":")
} else {
throw IllegalArgumentException("Unsupported URI format: $uri")
}
} catch (e: Exception) {
Log.d(TAG, "Failed to parse ID from URI: $uri", e)
null
}
}
private fun directoryExists(path: String): Boolean {
return try {
val file = File(path)
file.isDirectory && file.exists()
} catch (e: SecurityException) {
Log.d(TAG, "Failed to check directoryExists: $path", e)
false
}
}
}
private val id: Long

View file

@ -0,0 +1,101 @@
/**************************************************************************/
/* AndroidRuntimePlugin.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.godot.plugin
import org.godotengine.godot.Godot
import org.godotengine.godot.variant.Callable
/**
* Provides access to the Android runtime capabilities.
*
* For example, from gdscript, developers can use [getApplicationContext] to access system services
* and check if the device supports vibration.
*
* var android_runtime = Engine.get_singleton("AndroidRuntime")
* if android_runtime:
* print("Checking if the device supports vibration")
* var vibrator_service = android_runtime.getApplicationContext().getSystemService("vibrator")
* if vibrator_service:
* if vibrator_service.hasVibrator():
* print("Vibration is supported on device!")
* else:
* printerr("Vibration is not supported on device")
* else:
* printerr("Unable to retrieve the vibrator service")
* else:
* printerr("Couldn't find AndroidRuntime singleton")
*
*
* Or it can be used to display an Android native toast from gdscript
*
* var android_runtime = Engine.get_singleton("AndroidRuntime")
* if android_runtime:
* var activity = android_runtime.getActivity()
*
* var toastCallable = func ():
* var ToastClass = JavaClassWrapper.wrap("android.widget.Toast")
* ToastClass.makeText(activity, "This is a test", ToastClass.LENGTH_LONG).show()
*
* activity.runOnUiThread(android_runtime.createRunnableFromGodotCallable(toastCallable))
* else:
* printerr("Unable to access android runtime")
*/
class AndroidRuntimePlugin(godot: Godot) : GodotPlugin(godot) {
override fun getPluginName() = "AndroidRuntime"
/**
* Provides access to the application context to GDScript
*/
@UsedByGodot
fun getApplicationContext() = activity?.applicationContext
/**
* Provides access to the host activity to GDScript
*/
@UsedByGodot
override fun getActivity() = super.getActivity()
/**
* Utility method used to create [Runnable] from Godot [Callable].
*/
@UsedByGodot
fun createRunnableFromGodotCallable(godotCallable: Callable): Runnable {
return Runnable { godotCallable.call() }
}
/**
* Utility method used to create [java.util.concurrent.Callable] from Godot [Callable].
*/
@UsedByGodot
fun createCallableFromGodotCallable(godotCallable: Callable): java.util.concurrent.Callable<Any> {
return java.util.concurrent.Callable { godotCallable.call() }
}
}

View file

@ -43,6 +43,7 @@ import androidx.annotation.Nullable;
import java.lang.reflect.Constructor;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@ -82,6 +83,9 @@ public final class GodotPluginRegistry {
* Retrieve the full set of loaded plugins.
*/
public Collection<GodotPlugin> getAllPlugins() {
if (registry.isEmpty()) {
return Collections.emptyList();
}
return registry.values();
}

View file

@ -50,7 +50,7 @@ public final class SignalInfo {
}
this.name = signalName;
this.paramTypes = paramTypes == null ? new Class<?>[ 0 ] : paramTypes;
this.paramTypes = paramTypes == null ? new Class<?>[0] : paramTypes;
this.paramTypesNames = new String[this.paramTypes.length];
for (int i = 0; i < this.paramTypes.length; i++) {
this.paramTypesNames[i] = this.paramTypes[i].getName();

View file

@ -37,6 +37,7 @@ import android.os.SystemClock
import android.os.Trace
import android.util.Log
import org.godotengine.godot.BuildConfig
import org.godotengine.godot.error.Error
import org.godotengine.godot.io.file.FileAccessFlags
import org.godotengine.godot.io.file.FileAccessHandler
import org.json.JSONObject
@ -128,8 +129,8 @@ fun dumpBenchmark(fileAccessHandler: FileAccessHandler? = null, filepath: String
Log.i(TAG, "BENCHMARK:\n$printOut")
if (fileAccessHandler != null && !filepath.isNullOrBlank()) {
val fileId = fileAccessHandler.fileOpen(filepath, FileAccessFlags.WRITE)
if (fileId != FileAccessHandler.INVALID_FILE_ID) {
val (fileError, fileId) = fileAccessHandler.fileOpen(filepath, FileAccessFlags.WRITE)
if (fileError == Error.OK) {
val jsonOutput = JSONObject(benchmarkTracker.toMap()).toString(4)
fileAccessHandler.fileWrite(fileId, ByteBuffer.wrap(jsonOutput.toByteArray()))
fileAccessHandler.fileClose(fileId)

View file

@ -1,5 +1,5 @@
/**************************************************************************/
/* FileErrors.kt */
/* DeviceUtils.kt */
/**************************************************************************/
/* This file is part of: */
/* GODOT ENGINE */
@ -28,26 +28,33 @@
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
/**************************************************************************/
package org.godotengine.godot.io.file
/**
* Contains utility methods for detecting specific devices.
*/
@file:JvmName("DeviceUtils")
package org.godotengine.godot.utils
import android.content.Context
import android.os.Build
/**
* Set of errors that may occur when performing data access.
* Returns true if running on Meta Horizon OS.
*/
internal enum class FileErrors(val nativeValue: Int) {
OK(0),
FAILED(-1),
FILE_NOT_FOUND(-2),
FILE_CANT_OPEN(-3),
INVALID_PARAMETER(-4);
companion object {
fun fromNativeError(error: Int): FileErrors? {
for (fileError in entries) {
if (fileError.nativeValue == error) {
return fileError
}
}
return null
}
}
fun isHorizonOSDevice(context: Context): Boolean {
return context.packageManager.hasSystemFeature("oculus.hardware.standalone_vr")
}
/**
* Returns true if running on PICO OS.
*/
fun isPicoOSDevice(): Boolean {
return ("Pico".equals(Build.BRAND, true))
}
/**
* Returns true if running on a native Android XR device.
*/
fun isNativeXRDevice(context: Context): Boolean {
return isHorizonOSDevice(context) || isPicoOSDevice()
}

View file

@ -0,0 +1,283 @@
/**************************************************************************/
/* DialogUtils.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.godot.utils
import android.app.Activity
import android.app.AlertDialog
import android.content.DialogInterface
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import org.godotengine.godot.R
import kotlin.math.abs
/**
* Utility class for managing dialogs.
*/
class DialogUtils {
companion object {
private val TAG = DialogUtils::class.java.simpleName
/**
* Invoked on dialog button press.
*/
@JvmStatic
private external fun dialogCallback(buttonIndex: Int)
/**
* Invoked on the input dialog submitted.
*/
@JvmStatic
private external fun inputDialogCallback(text: String)
/**
* Displays a dialog with dynamically arranged buttons based on their text length.
*
* The buttons are laid out in rows, with a maximum of 2 buttons per row. If a button's text
* is too long to fit within half the screen width, it occupies the entire row.
*
* @param activity The activity where the dialog will be displayed.
* @param title The title of the dialog.
* @param message The message displayed in the dialog.
* @param buttons An array of button labels to display.
*/
internal fun showDialog(activity: Activity, title: String, message: String, buttons: Array<String>) {
var dismissDialog: () -> Unit = {} // Helper to dismiss the Dialog when a button is clicked.
activity.runOnUiThread {
val builder = AlertDialog.Builder(activity)
builder.setTitle(title)
builder.setMessage(message)
val buttonHeight = activity.resources.getDimensionPixelSize(R.dimen.button_height)
val paddingHorizontal = activity.resources.getDimensionPixelSize(R.dimen.dialog_padding_horizontal)
val paddingVertical = activity.resources.getDimensionPixelSize(R.dimen.dialog_padding_vertical)
val buttonPadding = activity.resources.getDimensionPixelSize(R.dimen.button_padding)
// Create a vertical parent layout to hold all rows of buttons.
val parentLayout = LinearLayout(activity)
parentLayout.orientation = LinearLayout.VERTICAL
parentLayout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
// Horizontal row layout for arranging buttons.
var rowLayout = LinearLayout(activity)
rowLayout.orientation = LinearLayout.HORIZONTAL
rowLayout.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
// Calculate the maximum width for a button to allow two buttons per row.
val screenWidth = activity.resources.displayMetrics.widthPixels
val availableWidth = screenWidth - (2 * paddingHorizontal)
val maxButtonWidth = availableWidth / 2
buttons.forEachIndexed { index, buttonLabel ->
val button = Button(activity)
button.text = buttonLabel
button.isSingleLine = true
button.setPadding(buttonPadding, buttonPadding, buttonPadding, buttonPadding)
// Measure the button to determine its width.
button.measure(0, 0)
val buttonWidth = button.measuredWidth
val params = LinearLayout.LayoutParams(
if (buttonWidth > maxButtonWidth) LinearLayout.LayoutParams.MATCH_PARENT else 0,
buttonHeight
)
params.weight = if (buttonWidth > maxButtonWidth) 0f else 1f
button.layoutParams = params
// Handle full-width buttons by finalizing the current row, if needed.
if (buttonWidth > maxButtonWidth) {
if (rowLayout.childCount > 0) {
parentLayout.addView(rowLayout)
rowLayout = LinearLayout(activity)
rowLayout.orientation = LinearLayout.HORIZONTAL
}
// Add the full-width button directly to the parent layout.
parentLayout.addView(button)
} else {
rowLayout.addView(button)
// Finalize the row if it reaches 2 buttons.
if (rowLayout.childCount == 2) {
parentLayout.addView(rowLayout)
rowLayout = LinearLayout(activity)
rowLayout.orientation = LinearLayout.HORIZONTAL
}
// Handle the last button with incomplete row.
if (index == buttons.size - 1 && rowLayout.childCount > 0) {
parentLayout.addView(rowLayout)
}
}
button.setOnClickListener {
dialogCallback(index)
dismissDialog()
}
}
// Attach the parent layout to the dialog.
builder.setView(parentLayout)
val dialog = builder.create()
dismissDialog = {dialog.dismiss()}
dialog.setCancelable(false)
dialog.show()
}
}
/**
* This method shows a dialog with a text input field, allowing the user to input text.
*
* @param activity The activity where the input dialog will be displayed.
* @param title The title of the input dialog.
* @param message The message displayed in the input dialog.
* @param existingText The existing text that will be pre-filled in the input field.
*/
internal fun showInputDialog(activity: Activity, title: String, message: String, existingText: String) {
val inputField = EditText(activity)
val paddingHorizontal = activity.resources.getDimensionPixelSize(R.dimen.dialog_padding_horizontal)
val paddingVertical = activity.resources.getDimensionPixelSize(R.dimen.dialog_padding_vertical)
inputField.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
inputField.setText(existingText)
activity.runOnUiThread {
val builder = AlertDialog.Builder(activity)
builder.setMessage(message).setTitle(title).setView(inputField)
builder.setPositiveButton(R.string.dialog_ok) {
dialog: DialogInterface, id: Int ->
inputDialogCallback(inputField.text.toString())
dialog.dismiss()
}
val dialog = builder.create()
dialog.setCancelable(false)
dialog.show()
}
}
/**
* Displays a Snackbar with an optional action button.
*
* @param context The Context in which the Snackbar should be displayed.
* @param message The message to display in the Snackbar.
* @param duration The duration for which the Snackbar should be visible (in milliseconds).
* @param actionText (Optional) The text for the action button. If `null`, the button is hidden.
* @param actionCallback (Optional) A callback function to execute when the action button is clicked. If `null`, no action is performed.
*/
fun showSnackbar(activity: Activity, message: String, duration: Long = 3000, actionText: String? = null, action: (() -> Unit)? = null) {
activity.runOnUiThread {
val bottomMargin = activity.resources.getDimensionPixelSize(R.dimen.snackbar_bottom_margin)
val inflater = LayoutInflater.from(activity)
val customView = inflater.inflate(R.layout.snackbar, null)
val popupWindow = PopupWindow(
customView,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
val messageView = customView.findViewById<TextView>(R.id.snackbar_text)
messageView.text = message
val actionButton = customView.findViewById<Button>(R.id.snackbar_action)
if (actionText != null && action != null) {
actionButton.text = actionText
actionButton.visibility = View.VISIBLE
actionButton.setOnClickListener {
action.invoke()
popupWindow.dismiss()
}
} else {
actionButton.visibility = View.GONE
}
addSwipeToDismiss(customView, popupWindow)
popupWindow.showAtLocation(customView, Gravity.BOTTOM, 0, bottomMargin)
Handler(Looper.getMainLooper()).postDelayed({ popupWindow.dismiss() }, duration)
}
}
private fun addSwipeToDismiss(view: View, popupWindow: PopupWindow) {
view.setOnTouchListener(object : View.OnTouchListener {
private var initialX = 0f
private var dX = 0f
private val threshold = 300f // Swipe distance threshold.
override fun onTouch(v: View?, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = event.rawX
dX = view.translationX
}
MotionEvent.ACTION_MOVE -> {
val deltaX = event.rawX - initialX
view.translationX = dX + deltaX
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val finalX = event.rawX - initialX
if (abs(finalX) > threshold) {
// If swipe exceeds threshold, dismiss smoothly.
view.animate()
.translationX(if (finalX > 0) view.width.toFloat() else -view.width.toFloat())
.setDuration(200)
.withEndAction { popupWindow.dismiss() }
.start()
} else {
// If swipe is canceled, return smoothly.
view.animate()
.translationX(0f)
.setDuration(200)
.start()
}
}
}
return true
}
})
}
}
}

View file

@ -0,0 +1,117 @@
/**************************************************************************/
/* GameMenuUtils.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.godot.utils
import android.util.Log
import org.godotengine.godot.GodotLib
/**
* Utility class for accessing and using game menu APIs.
*/
object GameMenuUtils {
private val TAG = GameMenuUtils::class.java.simpleName
/**
* Enum representing the "run/window_placement/game_embed_mode" editor settings.
*/
enum class GameEmbedMode(internal val nativeValue: Int) {
DISABLED(-1), AUTO(0), ENABLED(1);
companion object {
internal const val SETTING_KEY = "run/window_placement/game_embed_mode"
@JvmStatic
internal fun fromNativeValue(nativeValue: Int): GameEmbedMode? {
for (mode in GameEmbedMode.entries) {
if (mode.nativeValue == nativeValue) {
return mode
}
}
return null
}
}
}
@JvmStatic
external fun setSuspend(enabled: Boolean)
@JvmStatic
external fun nextFrame()
@JvmStatic
external fun setNodeType(type: Int)
@JvmStatic
external fun setSelectMode(mode: Int)
@JvmStatic
external fun setSelectionVisible(visible: Boolean)
@JvmStatic
external fun setCameraOverride(enabled: Boolean)
@JvmStatic
external fun setCameraManipulateMode(mode: Int)
@JvmStatic
external fun resetCamera2DPosition()
@JvmStatic
external fun resetCamera3DPosition()
@JvmStatic
external fun playMainScene()
/**
* Returns [GameEmbedMode] stored in the editor settings.
*
* Must be called on the render thread.
*/
fun fetchGameEmbedMode(): GameEmbedMode {
try {
val gameEmbedModeValue = Integer.parseInt(GodotLib.getEditorSetting(GameEmbedMode.SETTING_KEY))
val gameEmbedMode = GameEmbedMode.fromNativeValue(gameEmbedModeValue) ?: GameEmbedMode.AUTO
return gameEmbedMode
} catch (e: Exception) {
Log.w(TAG, "Unable to retrieve game embed mode", e)
return GameEmbedMode.AUTO
}
}
/**
* Update the 'game_embed_mode' editor setting.
*
* Must be called on the render thread.
*/
fun saveGameEmbedMode(gameEmbedMode: GameEmbedMode) {
GodotLib.setEditorSetting(GameEmbedMode.SETTING_KEY, gameEmbedMode.nativeValue)
}
}

View file

@ -44,6 +44,7 @@ import android.provider.Settings;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
@ -66,6 +67,7 @@ public final class PermissionsUtil {
public static final int REQUEST_ALL_PERMISSION_REQ_CODE = 1001;
public static final int REQUEST_SINGLE_PERMISSION_REQ_CODE = 1002;
public static final int REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE = 2002;
public static final int REQUEST_INSTALL_PACKAGES_REQ_CODE = 3002;
private PermissionsUtil() {
}
@ -76,11 +78,11 @@ public final class PermissionsUtil {
* @param activity the caller activity for this method.
* @return true/false. "true" if permissions are already granted, "false" if a permissions request was dispatched.
*/
public static boolean requestPermissions(Activity activity, List<String> permissions) {
if (activity == null) {
return false;
}
public static boolean requestPermissions(@NonNull Activity activity, List<String> permissions) {
return requestPermissions(activity, permissions, REQUEST_ALL_PERMISSION_REQ_CODE);
}
private static boolean requestPermissions(@NonNull Activity activity, List<String> permissions, int requestCode) {
if (permissions == null || permissions.isEmpty()) {
return true;
}
@ -90,6 +92,7 @@ public final class PermissionsUtil {
return true;
}
boolean dispatchedPermissionsRequest = false;
Set<String> requestedPermissions = new HashSet<>();
for (String permission : permissions) {
try {
@ -104,11 +107,23 @@ public final class PermissionsUtil {
Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
activity.startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE);
}
dispatchedPermissionsRequest = true;
}
} else if (permission.equals(Manifest.permission.REQUEST_INSTALL_PACKAGES)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !activity.getPackageManager().canRequestPackageInstalls()) {
try {
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES);
intent.setData(Uri.parse(String.format("package:%s", activity.getPackageName())));
activity.startActivityForResult(intent, REQUEST_INSTALL_PACKAGES_REQ_CODE);
dispatchedPermissionsRequest = true;
} catch (Exception e) {
Log.e(TAG, "Unable to request permission " + Manifest.permission.REQUEST_INSTALL_PACKAGES);
}
}
} else {
PermissionInfo permissionInfo = getPermissionInfo(activity, permission);
int protectionLevel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? permissionInfo.getProtection() : permissionInfo.protectionLevel;
if (protectionLevel == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED) {
if ((protectionLevel & PermissionInfo.PROTECTION_DANGEROUS) == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Requesting permission " + permission);
requestedPermissions.add(permission);
}
@ -119,13 +134,12 @@ public final class PermissionsUtil {
}
}
if (requestedPermissions.isEmpty()) {
// If list is empty, all of dangerous permissions were granted.
return true;
if (!requestedPermissions.isEmpty()) {
activity.requestPermissions(requestedPermissions.toArray(new String[0]), requestCode);
dispatchedPermissionsRequest = true;
}
activity.requestPermissions(requestedPermissions.toArray(new String[0]), REQUEST_ALL_PERMISSION_REQ_CODE);
return false;
return !dispatchedPermissionsRequest;
}
/**
@ -134,57 +148,37 @@ public final class PermissionsUtil {
* @param activity the caller activity for this method.
* @return true/false. "true" if permission is already granted, "false" if a permission request was dispatched.
*/
public static boolean requestPermission(String permissionName, Activity activity) {
if (activity == null || TextUtils.isEmpty(permissionName)) {
return false;
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// Not necessary, asked on install already
public static boolean requestPermission(String permissionName, @NonNull Activity activity) {
if (TextUtils.isEmpty(permissionName)) {
return true;
}
final int requestCode;
final String updatedPermissionName;
switch (permissionName) {
case "RECORD_AUDIO":
case Manifest.permission.RECORD_AUDIO:
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
activity.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, REQUEST_RECORD_AUDIO_PERMISSION);
return false;
}
return true;
updatedPermissionName = Manifest.permission.RECORD_AUDIO;
requestCode = REQUEST_RECORD_AUDIO_PERMISSION;
break;
case "CAMERA":
case Manifest.permission.CAMERA:
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
activity.requestPermissions(new String[] { Manifest.permission.CAMERA }, REQUEST_CAMERA_PERMISSION);
return false;
}
return true;
updatedPermissionName = Manifest.permission.CAMERA;
requestCode = REQUEST_CAMERA_PERMISSION;
break;
case "VIBRATE":
case Manifest.permission.VIBRATE:
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.VIBRATE) != PackageManager.PERMISSION_GRANTED) {
activity.requestPermissions(new String[] { Manifest.permission.VIBRATE }, REQUEST_VIBRATE_PERMISSION);
return false;
}
return true;
updatedPermissionName = Manifest.permission.VIBRATE;
requestCode = REQUEST_VIBRATE_PERMISSION;
break;
default:
// Check if the given permission is a dangerous permission
try {
PermissionInfo permissionInfo = getPermissionInfo(activity, permissionName);
int protectionLevel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? permissionInfo.getProtection() : permissionInfo.protectionLevel;
if (protectionLevel == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission(activity, permissionName) != PackageManager.PERMISSION_GRANTED) {
activity.requestPermissions(new String[] { permissionName }, REQUEST_SINGLE_PERMISSION_REQ_CODE);
return false;
}
} catch (PackageManager.NameNotFoundException e) {
// Unknown permission - return false as it can't be granted.
Log.w(TAG, "Unable to identify permission " + permissionName, e);
return false;
}
return true;
updatedPermissionName = permissionName;
requestCode = REQUEST_SINGLE_PERMISSION_REQ_CODE;
break;
}
List<String> permissions = Collections.singletonList(updatedPermissionName);
return requestPermissions(activity, permissions, requestCode);
}
/**
@ -215,7 +209,7 @@ public final class PermissionsUtil {
try {
manifestPermissions = getManifestPermissions(activity);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
Log.e(TAG, "Unable to retrieve manifest permissions", e);
return false;
}
@ -242,7 +236,7 @@ public final class PermissionsUtil {
try {
manifestPermissions = getManifestPermissions(context);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
Log.e(TAG, "Unable to retrieve manifest permissions", e);
return new String[0];
}
if (manifestPermissions.isEmpty()) {
@ -259,7 +253,7 @@ public final class PermissionsUtil {
} else {
PermissionInfo permissionInfo = getPermissionInfo(context, manifestPermission);
int protectionLevel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? permissionInfo.getProtection() : permissionInfo.protectionLevel;
if (protectionLevel == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission(context, manifestPermission) == PackageManager.PERMISSION_GRANTED) {
if ((protectionLevel & PermissionInfo.PROTECTION_DANGEROUS) == PermissionInfo.PROTECTION_DANGEROUS && ContextCompat.checkSelfPermission(context, manifestPermission) == PackageManager.PERMISSION_GRANTED) {
grantedPermissions.add(manifestPermission);
}
}

View file

@ -24,6 +24,7 @@ package org.godotengine.godot.utils;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
@ -44,6 +45,9 @@ import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
*/
public final class ProcessPhoenix extends Activity {
private static final String KEY_RESTART_INTENTS = "phoenix_restart_intents";
// -- GODOT start --
private static final String KEY_RESTART_ACTIVITY_OPTIONS = "phoenix_restart_activity_options";
// -- GODOT end --
private static final String KEY_MAIN_PROCESS_PID = "phoenix_main_process_pid";
/**
@ -56,12 +60,23 @@ public final class ProcessPhoenix extends Activity {
triggerRebirth(context, getRestartIntent(context));
}
// -- GODOT start --
/**
* Call to restart the application process using the specified intents.
* <p>
* Behavior of the current process after invoking this method is undefined.
*/
public static void triggerRebirth(Context context, Intent... nextIntents) {
triggerRebirth(context, null, nextIntents);
}
/**
* Call to restart the application process using the specified intents launched with the given
* {@link ActivityOptions}.
* <p>
* Behavior of the current process after invoking this method is undefined.
*/
public static void triggerRebirth(Context context, Bundle activityOptions, Intent... nextIntents) {
if (nextIntents.length < 1) {
throw new IllegalArgumentException("intents cannot be empty");
}
@ -72,10 +87,12 @@ public final class ProcessPhoenix extends Activity {
intent.addFlags(FLAG_ACTIVITY_NEW_TASK); // In case we are called with non-Activity context.
intent.putParcelableArrayListExtra(KEY_RESTART_INTENTS, new ArrayList<>(Arrays.asList(nextIntents)));
intent.putExtra(KEY_MAIN_PROCESS_PID, Process.myPid());
if (activityOptions != null) {
intent.putExtra(KEY_RESTART_ACTIVITY_OPTIONS, activityOptions);
}
context.startActivity(intent);
}
// -- GODOT start --
/**
* Finish the activity and kill its process
*/
@ -112,9 +129,11 @@ public final class ProcessPhoenix extends Activity {
super.onCreate(savedInstanceState);
// -- GODOT start --
ArrayList<Intent> intents = getIntent().getParcelableArrayListExtra(KEY_RESTART_INTENTS);
startActivities(intents.toArray(new Intent[intents.size()]));
forceQuit(this, getIntent().getIntExtra(KEY_MAIN_PROCESS_PID, -1));
Intent launchIntent = getIntent();
ArrayList<Intent> intents = launchIntent.getParcelableArrayListExtra(KEY_RESTART_INTENTS);
Bundle activityOptions = launchIntent.getBundleExtra(KEY_RESTART_ACTIVITY_OPTIONS);
startActivities(intents.toArray(new Intent[intents.size()]), activityOptions);
forceQuit(this, launchIntent.getIntExtra(KEY_MAIN_PROCESS_PID, -1));
// -- GODOT end --
}

View file

@ -0,0 +1,94 @@
/**************************************************************************/
/* Callable.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.godot.variant
import androidx.annotation.Keep
/**
* Android version of a Godot built-in Callable type representing a method or a standalone function.
*/
@Keep
class Callable private constructor(private val nativeCallablePointer: Long) {
companion object {
/**
* Invoke method [methodName] on the Godot object specified by [godotObjectId]
*/
@JvmStatic
fun call(godotObjectId: Long, methodName: String, vararg methodParameters: Any): Any? {
return nativeCallObject(godotObjectId, methodName, methodParameters)
}
/**
* Invoke method [methodName] on the Godot object specified by [godotObjectId] during idle time.
*/
@JvmStatic
fun callDeferred(godotObjectId: Long, methodName: String, vararg methodParameters: Any) {
nativeCallObjectDeferred(godotObjectId, methodName, methodParameters)
}
@JvmStatic
private external fun nativeCall(pointer: Long, params: Array<out Any>): Any?
@JvmStatic
private external fun nativeCallObject(godotObjectId: Long, methodName: String, params: Array<out Any>): Any?
@JvmStatic
private external fun nativeCallObjectDeferred(godotObjectId: Long, methodName: String, params: Array<out Any>)
@JvmStatic
private external fun releaseNativePointer(nativePointer: Long)
}
/**
* Calls the method represented by this [Callable]. Arguments can be passed and should match the method's signature.
*/
internal fun call(vararg params: Any): Any? {
if (nativeCallablePointer == 0L) {
return null
}
return nativeCall(nativeCallablePointer, params)
}
/**
* Used to provide access to the native callable pointer to the native logic.
*/
private fun getNativePointer() = nativeCallablePointer
/** Note that [finalize] is deprecated and shouldn't be used, unfortunately its replacement,
* [java.lang.ref.Cleaner], is only available on Android api 33 and higher.
* So we resort to using it for the time being until our min api catches up to api 33.
**/
protected fun finalize() {
releaseNativePointer(nativeCallablePointer)
}
}