diff --git a/CMakeLists.txt b/CMakeLists.txt
index ff3fca1..e03f54a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -321,15 +321,23 @@ source_group("Binding Source" FILES ${BINDING_SOURCE} ${BINDING_HEADERS})
set(CMAKE_CXX_STANDARD 23)
-add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32
- ${MAIN_HEADERS}
- ${MAIN_SOURCE}
- ${BINDING_HEADERS}
- ${BINDING_SOURCE}
- ${EMBEDDED_SOURCE}
-)
-
-
+if(ANDROID)
+ add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32
+ ${MAIN_HEADERS}
+ ${MAIN_SOURCE}
+ ${BINDING_HEADERS}
+ ${BINDING_SOURCE}
+ ${EMBEDDED_SOURCE}
+ )
+else()
+ add_library(main SHARED
+ ${MAIN_HEADERS}
+ ${MAIN_SOURCE}
+ ${BINDING_HEADERS}
+ ${BINDING_SOURCE}
+ ${EMBEDDED_SOURCE}
+ )
+endif()
find_package(PkgConfig REQUIRED)
find_package(ZLIB REQUIRED)
diff --git a/PORTING.md b/PORTING.md
index 6990b3a..13e2d2c 100644
--- a/PORTING.md
+++ b/PORTING.md
@@ -4,18 +4,20 @@ Windows: 100% Состояние: Играбельно
Linux: 90% Состояние: В работе но уже играбельно
TODO:
* Fix Wallpaper manager
-*BSD: 30% Состояние: в работе, проблемы с компиляцией
+*BSD: 20% Состояние: в работе, проблемы с компиляцией
GNU/Hurd: 1% Состояние: В работе
Solaris/OpenSolaris: 1% Состояние: В работе
RedoxOS: 0% Состояние: Не хватает библеотек
-Android: 1% Состояние: В работе
+Android: 2% Состояние: В работе
MacOS: ? Состояние: Нет подходящего оборудования/людей
PS Vita: 0% Состояние: В работе
PSP: 0% Состояние: В работе
-Emspritein(web): 10% Состояние: Нет хватает библеотек
+Emspritein(web): 11% Состояние: Нет хватает библеотек
HaikuOS: 0% Состояние: В работе
IOS: 0% Состояние: Нет подходящего оборудования/людей
Nintendo 3DS: 0% Состояние: Нет подходящего оборудования/людей
XBOX: 0% Состояние: Нет лицензии
Risc OS: 0% Состояние: Неизвестно
DOS: 0.1% Состояние: Мысленный эксперемент, одна строчка кода для поддержка сделана XD
+PS2: 0% Состояние: Технически невозможно...(или нет?)
+PS1: 0% Состояние: Тот кто пишет этот файл даун
diff --git a/android-project/.gradle/8.12/checksums/checksums.lock b/android-project/.gradle/8.12/checksums/checksums.lock
new file mode 100644
index 0000000..8823999
Binary files /dev/null and b/android-project/.gradle/8.12/checksums/checksums.lock differ
diff --git a/android-project/.gradle/8.12/checksums/md5-checksums.bin b/android-project/.gradle/8.12/checksums/md5-checksums.bin
new file mode 100644
index 0000000..ba98a69
Binary files /dev/null and b/android-project/.gradle/8.12/checksums/md5-checksums.bin differ
diff --git a/android-project/.gradle/8.12/checksums/sha1-checksums.bin b/android-project/.gradle/8.12/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..2a8c9b5
Binary files /dev/null and b/android-project/.gradle/8.12/checksums/sha1-checksums.bin differ
diff --git a/android-project/.gradle/8.12/executionHistory/executionHistory.lock b/android-project/.gradle/8.12/executionHistory/executionHistory.lock
new file mode 100644
index 0000000..c1d5341
Binary files /dev/null and b/android-project/.gradle/8.12/executionHistory/executionHistory.lock differ
diff --git a/android-project/.gradle/8.12/fileChanges/last-build.bin b/android-project/.gradle/8.12/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/android-project/.gradle/8.12/fileChanges/last-build.bin differ
diff --git a/android-project/.gradle/8.12/fileHashes/fileHashes.bin b/android-project/.gradle/8.12/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..b476b49
Binary files /dev/null and b/android-project/.gradle/8.12/fileHashes/fileHashes.bin differ
diff --git a/android-project/.gradle/8.12/fileHashes/fileHashes.lock b/android-project/.gradle/8.12/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..cc543d9
Binary files /dev/null and b/android-project/.gradle/8.12/fileHashes/fileHashes.lock differ
diff --git a/android-project/.gradle/8.12/fileHashes/resourceHashesCache.bin b/android-project/.gradle/8.12/fileHashes/resourceHashesCache.bin
new file mode 100644
index 0000000..7f552b4
Binary files /dev/null and b/android-project/.gradle/8.12/fileHashes/resourceHashesCache.bin differ
diff --git a/android-project/.gradle/8.12/gc.properties b/android-project/.gradle/8.12/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/android-project/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/android-project/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 0000000..528fa3c
Binary files /dev/null and b/android-project/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/android-project/.gradle/buildOutputCleanup/cache.properties b/android-project/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 0000000..48cfd99
--- /dev/null
+++ b/android-project/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Sat Jun 27 18:04:35 SAMT 2026
+gradle.version=8.12
diff --git a/android-project/.gradle/config.properties b/android-project/.gradle/config.properties
new file mode 100644
index 0000000..24427f0
--- /dev/null
+++ b/android-project/.gradle/config.properties
@@ -0,0 +1,2 @@
+#Sat Jun 27 18:02:25 SAMT 2026
+java.home=/app/extra/jbr
diff --git a/android-project/.gradle/vcs-1/gc.properties b/android-project/.gradle/vcs-1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/android-project/.idea/.gitignore b/android-project/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/android-project/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/android-project/.idea/AndroidProjectSystem.xml b/android-project/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/android-project/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/caches/deviceStreaming.xml b/android-project/.idea/caches/deviceStreaming.xml
new file mode 100644
index 0000000..af0857e
--- /dev/null
+++ b/android-project/.idea/caches/deviceStreaming.xml
@@ -0,0 +1,1906 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/compiler.xml b/android-project/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/android-project/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/deploymentTargetSelector.xml b/android-project/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ca16a99
--- /dev/null
+++ b/android-project/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/deviceManager.xml b/android-project/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/android-project/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/gradle.xml b/android-project/.idea/gradle.xml
new file mode 100644
index 0000000..639c779
--- /dev/null
+++ b/android-project/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/migrations.xml b/android-project/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/android-project/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/misc.xml b/android-project/.idea/misc.xml
new file mode 100644
index 0000000..a4f09e2
--- /dev/null
+++ b/android-project/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/runConfigurations.xml b/android-project/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/android-project/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/.idea/vcs.xml b/android-project/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/android-project/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-project/app/.cxx/Debug/524c1b2d/arm64-v8a/.cmake/api/v1/query/client-agp/cache-v2 b/android-project/app/.cxx/Debug/524c1b2d/arm64-v8a/.cmake/api/v1/query/client-agp/cache-v2
new file mode 100644
index 0000000..e69de29
diff --git a/android-project/app/.cxx/Debug/524c1b2d/arm64-v8a/.cmake/api/v1/query/client-agp/cmakeFiles-v1 b/android-project/app/.cxx/Debug/524c1b2d/arm64-v8a/.cmake/api/v1/query/client-agp/cmakeFiles-v1
new file mode 100644
index 0000000..e69de29
diff --git a/android-project/app/.cxx/Debug/524c1b2d/arm64-v8a/.cmake/api/v1/query/client-agp/codemodel-v2 b/android-project/app/.cxx/Debug/524c1b2d/arm64-v8a/.cmake/api/v1/query/client-agp/codemodel-v2
new file mode 100644
index 0000000..e69de29
diff --git a/android-project/app/.cxx/Debug/524c1b2d/hash_key.txt b/android-project/app/.cxx/Debug/524c1b2d/hash_key.txt
new file mode 100644
index 0000000..47b067c
--- /dev/null
+++ b/android-project/app/.cxx/Debug/524c1b2d/hash_key.txt
@@ -0,0 +1,26 @@
+# Values used to calculate the hash in this folder name.
+# Should not depend on the absolute path of the project itself.
+# - AGP: 8.7.3.
+# - $NDK is the path to NDK 28.2.13676358.
+# - $PROJECT is the path to the parent folder of the root Gradle build file.
+# - $ABI is the ABI to be built with. The specific value doesn't contribute to the value of the hash.
+# - $HASH is the hash value computed from this text.
+# - $CMAKE is the path to CMake 3.22.1.
+# - $NINJA is the path to Ninja.
+-H$PROJECT
+-DCMAKE_SYSTEM_NAME=Android
+-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
+-DCMAKE_SYSTEM_VERSION=21
+-DANDROID_ABI=$ABI
+-DCMAKE_ANDROID_ARCH_ABI=$ABI
+-DANDROID_NDK=$NDK
+-DCMAKE_ANDROID_NDK=$NDK
+-DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake
+-DCMAKE_MAKE_PROGRAM=$NINJA
+-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$PROJECT/app/build/intermediates/cxx/Debug/$HASH/obj/$ABI
+-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=$PROJECT/app/build/intermediates/cxx/Debug/$HASH/obj/$ABI
+-DCMAKE_BUILD_TYPE=Debug
+-B$PROJECT/app/.cxx/Debug/$HASH/$ABI
+-GNinja
+-DANDROID_PLATFORM=android-21
+-DANDROID_STL=c++_static
\ No newline at end of file
diff --git a/android-project/app/build.gradle b/android-project/app/build.gradle
new file mode 100644
index 0000000..be22996
--- /dev/null
+++ b/android-project/app/build.gradle
@@ -0,0 +1,63 @@
+plugins {
+ id 'com.android.application'
+}
+
+def buildWithCMake = true
+
+android {
+ namespace = "org.catwindowteam.sunshine"
+ compileSdkVersion 35
+ ndkVersion = "28.2.13676358"
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 35
+ versionCode 1
+ versionName "1.0"
+ externalNativeBuild {
+ ndkBuild {
+ arguments "APP_PLATFORM=android-21"
+ // abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
+ abiFilters 'arm64-v8a'
+ }
+ cmake {
+ arguments "-DANDROID_PLATFORM=android-21", "-DANDROID_STL=c++_static"
+ // abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
+ abiFilters 'arm64-v8a'
+ }
+ }
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+ applicationVariants.all { variant ->
+ tasks["merge${variant.name.capitalize()}Assets"]
+ .dependsOn("externalNativeBuild${variant.name.capitalize()}")
+ }
+ if (!project.hasProperty('EXCLUDE_NATIVE_LIBS')) {
+ sourceSets.main {
+ jniLibs.srcDir 'libs'
+ }
+ externalNativeBuild {
+ if (buildWithCMake) {
+ cmake {
+ path '../CMakeLists.txt'
+ }
+ } else {
+ ndkBuild {
+ path 'jni/Android.mk'
+ }
+ }
+ }
+
+ }
+ lint {
+ abortOnError = false
+ }
+}
+
+dependencies {
+ implementation fileTree(include: ['*.jar'], dir: 'libs')
+}
diff --git a/android-project/app/jni/Android.mk b/android-project/app/jni/Android.mk
new file mode 100644
index 0000000..5053e7d
--- /dev/null
+++ b/android-project/app/jni/Android.mk
@@ -0,0 +1 @@
+include $(call all-subdir-makefiles)
diff --git a/android-project/app/jni/Application.mk b/android-project/app/jni/Application.mk
new file mode 100644
index 0000000..1f7c0c1
--- /dev/null
+++ b/android-project/app/jni/Application.mk
@@ -0,0 +1,10 @@
+
+# Uncomment this if you're using STL in your project
+# You can find more information here:
+# https://developer.android.com/ndk/guides/cpp-support
+# APP_STL := c++_shared
+
+APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
+
+# Min runtime API level
+APP_PLATFORM=android-21
diff --git a/android-project/app/jni/CMakeLists.txt b/android-project/app/jni/CMakeLists.txt
new file mode 100644
index 0000000..fec53f2
--- /dev/null
+++ b/android-project/app/jni/CMakeLists.txt
@@ -0,0 +1,15 @@
+cmake_minimum_required(VERSION 3.6)
+
+project(SUNSHINE)
+
+# SDL sources are in a subfolder named "SDL"
+add_subdirectory(SDL)
+
+# Compilation of companion libraries
+#add_subdirectory(SDL_image)
+#add_subdirectory(SDL_mixer)
+#add_subdirectory(SDL_ttf)
+
+# Your game and its CMakeLists.txt are in a subfolder named "src"
+add_subdirectory(src)
+
diff --git a/android-project/app/jni/src/Android.mk b/android-project/app/jni/src/Android.mk
new file mode 100644
index 0000000..61672d4
--- /dev/null
+++ b/android-project/app/jni/src/Android.mk
@@ -0,0 +1,19 @@
+LOCAL_PATH := $(call my-dir)
+
+include $(CLEAR_VARS)
+
+LOCAL_MODULE := main
+
+# Add your application source files here...
+LOCAL_SRC_FILES := \
+ YourSourceHere.c
+
+SDL_PATH := ../SDL # SDL
+
+LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(SDL_PATH)/include # SDL
+
+LOCAL_SHARED_LIBRARIES := SDL3
+
+LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -lOpenSLES -llog -landroid # SDL
+
+include $(BUILD_SHARED_LIBRARY)
diff --git a/android-project/app/jni/src/CMakeLists.txt b/android-project/app/jni/src/CMakeLists.txt
new file mode 100644
index 0000000..0212837
--- /dev/null
+++ b/android-project/app/jni/src/CMakeLists.txt
@@ -0,0 +1,12 @@
+cmake_minimum_required(VERSION 3.6)
+
+project(my_app)
+
+if(NOT TARGET SDL3::SDL3)
+ find_package(SDL3 CONFIG REQUIRED)
+endif()
+
+add_library(main SHARED
+ YourSourceHere.c
+)
+target_link_libraries(main PRIVATE SDL3::SDL3)
diff --git a/android-project/app/jni/src/YourSourceHere.c b/android-project/app/jni/src/YourSourceHere.c
new file mode 100644
index 0000000..87b8297
--- /dev/null
+++ b/android-project/app/jni/src/YourSourceHere.c
@@ -0,0 +1,26 @@
+#include
+#include
+
+/* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */
+/* */
+/* Remove this source, and replace with your SDL sources */
+/* */
+/* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */
+
+int main(int argc, char *argv[]) {
+ (void)argc;
+ (void)argv;
+ if (!SDL_Init(SDL_INIT_EVENTS | SDL_INIT_VIDEO)) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed (%s)", SDL_GetError());
+ return 1;
+ }
+
+ if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, "Hello World",
+ "!! Your SDL project successfully runs on Android !!", NULL)) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_ShowSimpleMessageBox failed (%s)", SDL_GetError());
+ return 1;
+ }
+
+ SDL_Quit();
+ return 0;
+}
diff --git a/android-project/app/proguard-rules.pro b/android-project/app/proguard-rules.pro
new file mode 100644
index 0000000..975554b
--- /dev/null
+++ b/android-project/app/proguard-rules.pro
@@ -0,0 +1,78 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in [sdk]/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# https://developer.android.com/build/shrink-code
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.SDLActivity {
+ java.lang.String nativeGetHint(java.lang.String); # Java-side doesn't use this, so it gets minified, but C-side still tries to register it
+ java.lang.String clipboardGetText();
+ boolean clipboardHasText();
+ void clipboardSetText(java.lang.String);
+ int createCustomCursor(int[], int, int, int, int);
+ void destroyCustomCursor(int);
+ android.app.Activity getContext();
+ boolean getManifestEnvironmentVariables();
+ android.view.Surface getNativeSurface();
+ void initTouch();
+ boolean isAndroidTV();
+ boolean isChromebook();
+ boolean isDeXMode();
+ boolean isTablet();
+ void manualBackButton();
+ int messageboxShowMessageBox(int, java.lang.String, java.lang.String, int[], int[], java.lang.String[], int[]);
+ void minimizeWindow();
+ boolean openURL(java.lang.String);
+ void requestPermission(java.lang.String, int);
+ boolean showToast(java.lang.String, int, int, int, int);
+ boolean sendMessage(int, int);
+ boolean setActivityTitle(java.lang.String);
+ boolean setCustomCursor(int);
+ void setOrientation(int, int, boolean, java.lang.String);
+ boolean setRelativeMouseEnabled(boolean);
+ boolean setSystemCursor(int);
+ void setWindowStyle(boolean);
+ boolean shouldMinimizeOnFocusLoss();
+ boolean showTextInput(int, int, int, int, int);
+ boolean supportsRelativeMouse();
+ int openFileDescriptor(java.lang.String, java.lang.String);
+ boolean showFileDialog(java.lang.String[], boolean, int, java.lang.String, int);
+ java.lang.String getPreferredLocales();
+ java.lang.String formatLocale(java.util.Locale);
+}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.HIDDeviceManager {
+ void closeDevice(int);
+ boolean initialize(boolean, boolean);
+ boolean openDevice(int);
+ boolean readReport(int, byte[], boolean);
+ int writeReport(int, byte[], boolean);
+}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.SDLAudioManager {
+ void registerAudioDeviceCallback();
+ void unregisterAudioDeviceCallback();
+ void audioSetThreadPriority(boolean, int);
+}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.SDLControllerManager {
+ void joystickSetSensorsEnabled(int, boolean);
+ void detectDevices();
+ void joystickSetLED(int, int, int, int);
+ void detectHapticDevices();
+ void hapticRun(int, float, int);
+ void hapticRumble(int, float, float, int);
+ void hapticStop(int);
+}
diff --git a/android-project/app/src/main/AndroidManifest.xml b/android-project/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1111a22
--- /dev/null
+++ b/android-project/app/src/main/AndroidManifest.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDevice.java b/android-project/app/src/main/java/org/libsdl/app/HIDDevice.java
new file mode 100644
index 0000000..f960953
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDevice.java
@@ -0,0 +1,21 @@
+package org.libsdl.app;
+
+import android.hardware.usb.UsbDevice;
+
+interface HIDDevice
+{
+ public int getId();
+ public int getVendorId();
+ public int getProductId();
+ public String getSerialNumber();
+ public int getVersion();
+ public String getManufacturerName();
+ public String getProductName();
+ public UsbDevice getDevice();
+ public boolean open();
+ public int writeReport(byte[] report, boolean feature);
+ public boolean readReport(byte[] report, boolean feature);
+ public void setFrozen(boolean frozen);
+ public void close();
+ public void shutdown();
+}
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
new file mode 100644
index 0000000..bcd8806
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
@@ -0,0 +1,829 @@
+package org.libsdl.app;
+
+import android.content.Context;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothGattService;
+import android.hardware.usb.UsbDevice;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.os.*;
+
+//import com.android.internal.util.HexDump;
+
+import java.lang.Runnable;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.UUID;
+
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+
+class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+ private HIDDeviceManager mManager;
+ private BluetoothDevice mDevice;
+ private int mDeviceId;
+ private BluetoothGatt mGatt;
+ private boolean mIsRegistered = false;
+ private boolean mIsConnected = false;
+ private boolean mIsChromebook = false;
+ private boolean mIsReconnecting = false;
+ private boolean mHasEnabledNotifications = false;
+ private boolean mHasSeenInputUpdate = false;
+ private boolean mFrozen = false;
+ private LinkedList mOperations;
+ GattOperation mCurrentOperation = null;
+ private Handler mHandler;
+ private int mProductId = -1;
+ private int mReportId = 0;
+ private UUID mInputCharacteristic;
+
+ private static final int D0G_BLE2_PID = 0x1106;
+ private static final int TRITON_BLE_PID = 0x1303;
+
+
+ private static final int TRANSPORT_AUTO = 0;
+ private static final int TRANSPORT_BREDR = 1;
+ private static final int TRANSPORT_LE = 2;
+
+ private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
+
+ static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+ static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+ static final UUID inputCharacteristicTriton_0x45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3");
+ static final UUID inputCharacteristicTriton_0x47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3");
+ static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
+ static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
+
+ private HashMap mOutputReportChars = new HashMap();
+
+ static class GattOperation {
+ private enum Operation {
+ CHR_READ,
+ CHR_WRITE,
+ ENABLE_NOTIFICATION
+ }
+
+ Operation mOp;
+ UUID mUuid;
+ byte[] mValue;
+ BluetoothGatt mGatt;
+ boolean mResult = true;
+ int mDelayMs = 0;
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ }
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, int delayMs) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ mDelayMs = delayMs;
+ }
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ mValue = value;
+ }
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value, int delayMs) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ mValue = value;
+ mDelayMs = delayMs;
+ }
+
+ public void run() {
+ // This is executed in main thread
+ BluetoothGattCharacteristic chr;
+
+ switch (mOp) {
+ case CHR_READ:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Reading characteristic " + chr.getUuid());
+ if (!mGatt.readCharacteristic(chr)) {
+ Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case CHR_WRITE:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
+ chr.setValue(mValue);
+ if (!mGatt.writeCharacteristic(chr)) {
+ Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case ENABLE_NOTIFICATION:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing descriptor of " + chr.getUuid());
+ if (chr != null) {
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ int properties = chr.getProperties();
+ byte[] value;
+ if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
+ value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
+ } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
+ value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
+ } else {
+ Log.e(TAG, "Unable to start notifications on input characteristic");
+ mResult = false;
+ return;
+ }
+
+ mGatt.setCharacteristicNotification(chr, true);
+ cccd.setValue(value);
+ if (!mGatt.writeDescriptor(cccd)) {
+ Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
+ mResult = false;
+ return;
+ }
+ mResult = true;
+ }
+ }
+ }
+ }
+
+ public boolean finish() {
+ return mResult;
+ }
+
+ public int getDelayMs() { return mDelayMs; }
+
+ private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+ BluetoothGattService valveService = mGatt.getService(steamControllerService);
+ if (valveService == null)
+ return null;
+ return valveService.getCharacteristic(uuid);
+ }
+
+ static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.CHR_READ, uuid);
+ }
+
+ static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
+ return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
+ }
+
+ static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
+ }
+
+ static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid, int delayMs) {
+ return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid, delayMs);
+ }
+ }
+
+ HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
+ mManager = manager;
+ mDevice = device;
+ mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
+ mIsRegistered = false;
+ mIsChromebook = SDLActivity.isChromebook();
+ mOperations = new LinkedList();
+ mHandler = new Handler(Looper.getMainLooper());
+
+ mGatt = connectGatt();
+ mHasEnabledNotifications = false;
+ mHasSeenInputUpdate = false;
+ // final HIDDeviceBLESteamController finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // void run() {
+ // finalThis.checkConnectionForChromebookIssue();
+ // }
+ // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ String getIdentifier() {
+ return String.format("SteamController.%s", mDevice.getAddress());
+ }
+
+ BluetoothGatt getGatt() {
+ return mGatt;
+ }
+
+ // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
+ // of TRANSPORT_LE. Let's force ourselves to connect low energy.
+ private BluetoothGatt connectGatt(boolean managed) {
+ if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
+ try {
+ return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
+ } catch (Exception e) {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ } else {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ }
+
+ private BluetoothGatt connectGatt() {
+ return connectGatt(false);
+ }
+
+ protected int getConnectionState() {
+
+ Context context = mManager.getContext();
+ if (context == null) {
+ // We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (btManager == null) {
+ // This device doesn't support Bluetooth. We should never be here, because how did
+ // we instantiate a device to start with?
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
+ }
+
+ void reconnect() {
+
+ if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
+ mGatt.disconnect();
+ mGatt = connectGatt();
+ }
+
+ }
+
+ protected void checkConnectionForChromebookIssue() {
+ if (!mIsChromebook) {
+ // We only do this on Chromebooks, because otherwise it's really annoying to just attempt
+ // over and over.
+ return;
+ }
+
+ int connectionState = getConnectionState();
+
+ switch (connectionState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ if (!mIsConnected) {
+ // We are in the Bad Chromebook Place. We can force a disconnect
+ // to try to recover.
+ Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ else if (!isRegistered()) {
+ if (mGatt.getServices().size() > 0) {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
+ probeService(this);
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
+ return;
+ }
+ break;
+
+ case BluetoothProfile.STATE_DISCONNECTED:
+ Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
+
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+
+ case BluetoothProfile.STATE_CONNECTING:
+ Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
+ break;
+ }
+
+ final HIDDeviceBLESteamController finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.checkConnectionForChromebookIssue();
+ }
+ }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ private boolean isRegistered() {
+ return mIsRegistered;
+ }
+
+ private void setRegistered() {
+ mIsRegistered = true;
+ }
+
+ private boolean probeService(HIDDeviceBLESteamController controller) {
+
+ if (isRegistered()) {
+ return true;
+ }
+
+ if (!mIsConnected) {
+ return false;
+ }
+
+ Log.v(TAG, "probeService controller=" + controller);
+
+ for (BluetoothGattService service : mGatt.getServices()) {
+ if (service.getUuid().equals(steamControllerService)) {
+ Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
+
+ for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
+ if (chr.getUuid().equals(inputCharacteristicTriton_0x45)) {
+ Log.v(TAG, "Found Triton input characteristic 0x45");
+ mProductId = TRITON_BLE_PID;
+ mReportId = 0x45;
+ mInputCharacteristic = chr.getUuid();
+ } else if (chr.getUuid().equals(inputCharacteristicTriton_0x47)) {
+ Log.v(TAG, "Found Triton input characteristic 0x47");
+ mProductId = TRITON_BLE_PID;
+ mReportId = 0x47;
+ mInputCharacteristic = chr.getUuid();
+ } else if (chr.getUuid().equals(inputCharacteristicD0G)) {
+ Log.v(TAG, "Found D0G input characteristic");
+ mProductId = D0G_BLE2_PID;
+ mReportId = 0x03;
+ mInputCharacteristic = chr.getUuid();
+ } else {
+ Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE);
+ Matcher matcher = reportPattern.matcher(chr.getUuid().toString());
+
+ if (matcher.find()) {
+ try {
+ int reportId = Integer.parseInt(matcher.group(1), 16);
+
+ reportId -= 0x35;
+ if (reportId >= 0x80) {
+ // This is a Triton output report characteristic that we need to care about.
+ Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16));
+ mOutputReportChars.put(reportId, chr);
+ }
+ }
+ catch (NumberFormatException nfe) {
+ Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString());
+ }
+ }
+ }
+ }
+
+ for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
+ if (chr.getUuid().equals(mInputCharacteristic)) {
+ // Start notifications
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ enableNotification(chr.getUuid());
+ }
+ }
+ }
+ return true;
+ }
+ }
+
+ if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
+ Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
+ mIsConnected = false;
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+
+ return false;
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private void finishCurrentGattOperation() {
+ GattOperation op = null;
+ synchronized (mOperations) {
+ if (mCurrentOperation != null) {
+ op = mCurrentOperation;
+ mCurrentOperation = null;
+ }
+ }
+ if (op != null) {
+ boolean result = op.finish(); // TODO: Maybe in main thread as well?
+
+ // Our operation failed, let's add it back to the beginning of our queue.
+ if (!result) {
+ mOperations.addFirst(op);
+ }
+ }
+ executeNextGattOperation();
+ }
+
+ private void executeNextGattOperation() {
+ synchronized (mOperations) {
+ if (mCurrentOperation != null)
+ return;
+
+ if (mOperations.isEmpty())
+ return;
+
+ mCurrentOperation = mOperations.removeFirst();
+ }
+
+ Runnable gattOperationRunnable = new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mOperations) {
+ if (mCurrentOperation == null) {
+ Log.e(TAG, "Current operation null in executor?");
+ return;
+ }
+
+ mCurrentOperation.run();
+ // now wait for the GATT callback and when it comes, finish this operation
+ }
+ }
+ };
+
+ if (mCurrentOperation.getDelayMs() == 0) {
+ // Run in main thread
+ mHandler.post(gattOperationRunnable);
+ }
+ else {
+ // If we have a delay on this operation, wait before we post it.
+ mHandler.postDelayed(gattOperationRunnable, mCurrentOperation.getDelayMs());
+ }
+
+ }
+
+ private void queueGattOperation(GattOperation op) {
+ synchronized (mOperations) {
+ mOperations.add(op);
+ }
+ executeNextGattOperation();
+ }
+
+ private void enableNotification(UUID chrUuid) {
+ // Add a 500ms delay to notification write for Amazon Fire TV devices, as otherwise if we do this too quickly after connecting
+ // it will return success and then silently drop the operation on the floor.
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid, 500);
+ queueGattOperation(op);
+
+ // Amazon Fire devices can also silently timeout on writeDescriptor, so
+ // set up a little delayed check that will attempt to write a second time.
+ //
+ // While this only seems to be needed on Amazon Fire TV devices at present, it
+ // doesn't hurt to have a retry on other devices as well.
+ //
+ final HIDDeviceBLESteamController finalThis = this;
+ final UUID finalUuid = chrUuid;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (!finalThis.mHasEnabledNotifications) {
+
+ if (finalThis.mHasSeenInputUpdate) {
+ // Amazon Five devices may have enabled notifications on the input characteristic and not given us a callback. If we've seen
+ // input reports, though, somewhat by definition notifications are enabled.
+ Log.w(TAG, "WriteDescriptor has never returned, but we've seen input reports. Moving on with controller initialization.");
+ finalThis.mHasEnabledNotifications = true;
+ finalThis.enableValveMode();
+ return;
+ }
+
+ // Give one more try.
+ GattOperation retry = HIDDeviceBLESteamController.GattOperation.enableNotification(finalThis.mGatt, finalUuid, 500);
+ finalThis.queueGattOperation(retry);
+ }
+ }
+ }, 1000);
+ }
+
+ void writeCharacteristic(UUID uuid, byte[] value) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
+ queueGattOperation(op);
+ }
+
+ void readCharacteristic(UUID uuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
+ queueGattOperation(op);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////////// BluetoothGattCallback overridden methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
+ //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
+ mIsReconnecting = false;
+ if (newState == 2) {
+ mIsConnected = true;
+ // Run directly, without GattOperation
+ if (!isRegistered()) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mGatt.discoverServices();
+ }
+ });
+ }
+ }
+ else if (newState == 0) {
+ mIsConnected = false;
+ }
+
+ // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onServicesDiscovered status=" + status);
+ if (status == 0) {
+ if (gatt.getServices().size() == 0) {
+ Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
+ mIsReconnecting = true;
+ mIsConnected = false;
+ gatt.disconnect();
+ mGatt = connectGatt(false);
+ } else {
+ if (getProductId() == TRITON_BLE_PID) {
+ // Android will not properly play well with Data Length Extensions without manually requesting a large MTU,
+ // and Triton controllers require DLE support.
+ //
+ // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change
+ // this value. It is functionally "please enable data length extensions" on some Android builds.
+ mGatt.requestMtu(517);
+ }
+
+ probeService(this);
+ }
+ }
+ }
+
+ @Override
+ public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceReportResponse(getId(), characteristic.getValue());
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ @Override
+ public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic)) {
+ // Only register controller with the native side once it has been fully configured
+ if (!isRegistered()) {
+ Log.v(TAG, "Registering Steam Controller with ID: " + getId());
+ mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId);
+ setRegistered();
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ @Override
+ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ // Enable this for verbose logging of controller input reports
+ //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
+
+ if (characteristic.getUuid().equals(mInputCharacteristic) && !mFrozen) {
+ mHasSeenInputUpdate = true;
+ mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
+ }
+ }
+
+ @Override
+ public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ //Log.v(TAG, "onDescriptorRead status=" + status);
+ }
+
+ private void enableValveMode()
+ {
+ BluetoothGattService valveService = mGatt.getService(steamControllerService);
+ if (valveService == null)
+ return;
+
+ BluetoothGattCharacteristic reportChr = valveService.getCharacteristic(reportCharacteristic);
+ if (reportChr != null) {
+ if (getProductId() == TRITON_BLE_PID) {
+ // For Triton we just mark things registered.
+ Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId());
+ mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId);
+ setRegistered();
+ } else {
+ // For the original controller, we need to manually enter Valve mode.
+ Log.v(TAG, "Writing report characteristic to enter valve mode");
+ reportChr.setValue(enterValveMode);
+ mGatt.writeCharacteristic(reportChr);
+ }
+ }
+ }
+
+ @Override
+ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
+ //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
+
+ if (chr.getUuid().equals(mInputCharacteristic)) {
+ mHasEnabledNotifications = true;
+ enableValveMode();
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ @Override
+ public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onReliableWriteCompleted status=" + status);
+ }
+
+ @Override
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ //Log.v(TAG, "onReadRemoteRssi status=" + status);
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ //Log.v(TAG, "onMtuChanged status=" + status);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////// Public API
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ // Valve Corporation
+ final int VALVE_USB_VID = 0x28DE;
+ return VALVE_USB_VID;
+ }
+
+ @Override
+ public int getProductId() {
+ if (mProductId > 0) {
+ // We've already set a product ID.
+ return mProductId;
+ }
+
+ if (mDevice.getName().startsWith("Steam Ctrl")) {
+ // We're a newer Triton device
+ mProductId = TRITON_BLE_PID;
+ } else {
+ // We're an OG Steam Controller
+ mProductId = D0G_BLE2_PID;
+ }
+
+ return mProductId;
+ }
+
+ @Override
+ public String getSerialNumber() {
+ // This will be read later via feature report by Steam
+ return "12345";
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ return "Valve Corporation";
+ }
+
+ @Override
+ public String getProductName() {
+ return "Steam Controller";
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return null;
+ }
+
+ @Override
+ public boolean open() {
+ return true;
+ }
+
+ @Override
+ public int writeReport(byte[] report, boolean feature) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted writeReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return -1;
+ }
+
+ if (feature) {
+ // We need to skip the first byte, as that doesn't go over the air
+ byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+ //Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report));
+ writeCharacteristic(reportCharacteristic, actual_report);
+ return report.length;
+ } else {
+ // If we're an original-recipe Steam Controller we just write to the characteristic directly.
+ if (getProductId() == D0G_BLE2_PID) {
+ //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
+ writeCharacteristic(reportCharacteristic, report);
+ return report.length;
+ }
+
+ // If we're a Triton, we need to find the correct report characteristic.
+ if (report.length > 0) {
+ int reportId = report[0] & 0xFF;
+ BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId);
+ if (targetedReportCharacteristic != null) {
+ byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+ //Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report));
+ writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report);
+ return report.length;
+ } else {
+ Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16));
+ }
+ }
+ }
+
+ return -1;
+ }
+
+ @Override
+ public boolean readReport(byte[] report, boolean feature) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted readReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return false;
+ }
+
+ if (feature) {
+ readCharacteristic(reportCharacteristic);
+ return true;
+ } else {
+ // Not implemented
+ return false;
+ }
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+
+ BluetoothGatt g = mGatt;
+ if (g != null) {
+ g.disconnect();
+ g.close();
+ mGatt = null;
+ }
+ mManager = null;
+ mIsRegistered = false;
+ mIsConnected = false;
+ mOperations.clear();
+ }
+
+}
+
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
new file mode 100644
index 0000000..691416c
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
@@ -0,0 +1,698 @@
+package org.libsdl.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.os.Build;
+import android.util.Log;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.hardware.usb.*;
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+
+public class HIDDeviceManager {
+ private static final String TAG = "hidapi";
+ private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION";
+
+ private static HIDDeviceManager sManager;
+ private static int sManagerRefCount = 0;
+
+ static public HIDDeviceManager acquire(Context context) {
+ if (sManagerRefCount == 0) {
+ sManager = new HIDDeviceManager(context);
+ }
+ ++sManagerRefCount;
+ return sManager;
+ }
+
+ static public void release(HIDDeviceManager manager) {
+ if (manager == sManager) {
+ --sManagerRefCount;
+ if (sManagerRefCount == 0) {
+ sManager.close();
+ sManager = null;
+ }
+ }
+ }
+
+ private Context mContext;
+ private HashMap mDevicesById = new HashMap();
+ private HashMap mBluetoothDevices = new HashMap();
+ private int mNextDeviceId = 0;
+ private SharedPreferences mSharedPreferences = null;
+ private boolean mIsChromebook = false;
+ private UsbManager mUsbManager;
+ private Handler mHandler;
+ private BluetoothManager mBluetoothManager;
+ private List mLastBluetoothDevices;
+
+ private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDeviceAttached(usbDevice);
+ } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDeviceDetached(usbDevice);
+ } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
+ }
+ }
+ };
+
+ private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ // Bluetooth device was connected. If it was a Steam Controller, handle it
+ if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Bluetooth device connected: " + device);
+
+ if (isSteamController(device)) {
+ connectBluetoothDevice(device);
+ }
+ }
+
+ // Bluetooth device was disconnected, remove from controller manager (if any)
+ if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Bluetooth device disconnected: " + device);
+
+ disconnectBluetoothDevice(device);
+ }
+ }
+ };
+
+ private HIDDeviceManager(final Context context) {
+ mContext = context;
+
+ HIDDeviceRegisterCallback();
+
+ mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
+ mIsChromebook = SDLActivity.isChromebook();
+
+// if (shouldClear) {
+// SharedPreferences.Editor spedit = mSharedPreferences.edit();
+// spedit.clear();
+// spedit.apply();
+// }
+// else
+ {
+ mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
+ }
+ }
+
+ Context getContext() {
+ return mContext;
+ }
+
+ int getDeviceIDForIdentifier(String identifier) {
+ SharedPreferences.Editor spedit = mSharedPreferences.edit();
+
+ int result = mSharedPreferences.getInt(identifier, 0);
+ if (result == 0) {
+ result = mNextDeviceId++;
+ spedit.putInt("next_device_id", mNextDeviceId);
+ }
+
+ spedit.putInt(identifier, result);
+ spedit.apply();
+ return result;
+ }
+
+ private void initializeUSB() {
+ mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
+ if (mUsbManager == null) {
+ return;
+ }
+
+ /*
+ // Logging
+ for (UsbDevice device : mUsbManager.getDeviceList().values()) {
+ Log.i(TAG,"Path: " + device.getDeviceName());
+ Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
+ Log.i(TAG,"Product: " + device.getProductName());
+ Log.i(TAG,"ID: " + device.getDeviceId());
+ Log.i(TAG,"Class: " + device.getDeviceClass());
+ Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
+ Log.i(TAG,"Vendor ID " + device.getVendorId());
+ Log.i(TAG,"Product ID: " + device.getProductId());
+ Log.i(TAG,"Interface count: " + device.getInterfaceCount());
+ Log.i(TAG,"---------------------------------------");
+
+ // Get interface details
+ for (int index = 0; index < device.getInterfaceCount(); index++) {
+ UsbInterface mUsbInterface = device.getInterface(index);
+ Log.i(TAG," ***** *****");
+ Log.i(TAG," Interface index: " + index);
+ Log.i(TAG," Interface ID: " + mUsbInterface.getId());
+ Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass());
+ Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass());
+ Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol());
+ Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount());
+
+ // Get endpoint details
+ for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
+ {
+ UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
+ Log.i(TAG," ++++ ++++ ++++");
+ Log.i(TAG," Endpoint index: " + epi);
+ Log.i(TAG," Attributes: " + mEndpoint.getAttributes());
+ Log.i(TAG," Direction: " + mEndpoint.getDirection());
+ Log.i(TAG," Number: " + mEndpoint.getEndpointNumber());
+ Log.i(TAG," Interval: " + mEndpoint.getInterval());
+ Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize());
+ Log.i(TAG," Type: " + mEndpoint.getType());
+ }
+ }
+ }
+ Log.i(TAG," No more devices connected.");
+ */
+
+ // Register for USB broadcasts and permission completions
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
+ filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
+ if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */
+ mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED);
+ } else {
+ mContext.registerReceiver(mUsbBroadcast, filter);
+ }
+
+ for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
+ handleUsbDeviceAttached(usbDevice);
+ }
+ }
+
+ UsbManager getUSBManager() {
+ return mUsbManager;
+ }
+
+ private void shutdownUSB() {
+ try {
+ mContext.unregisterReceiver(mUsbBroadcast);
+ } catch (Exception e) {
+ // We may not have registered, that's okay
+ }
+ }
+
+ private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
+ if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
+ return true;
+ }
+ if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
+ final int XB360_IFACE_SUBCLASS = 93;
+ final int XB360_IFACE_PROTOCOL = 1; // Wired
+ final int XB360W_IFACE_PROTOCOL = 129; // Wireless
+ final int[] SUPPORTED_VENDORS = {
+ 0x0079, // GPD Win 2
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x046d, // Logitech
+ 0x056e, // Elecom
+ 0x06a3, // Saitek
+ 0x0738, // Mad Catz
+ 0x07ff, // Mad Catz
+ 0x0e6f, // PDP
+ 0x0f0d, // Hori
+ 0x1038, // SteelSeries
+ 0x11c9, // Nacon
+ 0x12ab, // Unknown
+ 0x1430, // RedOctane
+ 0x146b, // BigBen
+ 0x1532, // Razer Sabertooth
+ 0x15e4, // Numark
+ 0x162e, // Joytech
+ 0x1689, // Razer Onza
+ 0x1949, // Lab126, Inc.
+ 0x1bad, // Harmonix
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2c22, // Qanba
+ 0x2dc8, // 8BitDo
+ 0x3537, // GameSir
+ 0x37d7, // Flydigi
+ 0x9886, // ASTRO Gaming
+ };
+
+ if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
+ (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL ||
+ usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) {
+ int vendor_id = usbDevice.getVendorId();
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (vendor_id == supportedVid) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) {
+ final int XB1_IFACE_SUBCLASS = 71;
+ final int XB1_IFACE_PROTOCOL = 208;
+ final int[] SUPPORTED_VENDORS = {
+ 0x03f0, // HP
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x0738, // Mad Catz
+ 0x0b05, // ASUS
+ 0x0e6f, // PDP
+ 0x0f0d, // Hori
+ 0x10f5, // Turtle Beach
+ 0x1532, // Razer Wildcat
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x294b, // Snakebyte
+ 0x2dc8, // 8BitDo
+ 0x2e24, // Hyperkin
+ 0x2e95, // SCUF
+ 0x3285, // Nacon
+ 0x3537, // GameSir
+ 0x366c, // ByoWave
+ };
+
+ if (usbInterface.getId() == 0 &&
+ usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
+ usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
+ int vendor_id = usbDevice.getVendorId();
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (vendor_id == supportedVid) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private void handleUsbDeviceAttached(UsbDevice usbDevice) {
+ connectHIDDeviceUSB(usbDevice);
+ }
+
+ private void handleUsbDeviceDetached(UsbDevice usbDevice) {
+ List devices = new ArrayList();
+ for (HIDDevice device : mDevicesById.values()) {
+ if (usbDevice.equals(device.getDevice())) {
+ devices.add(device.getId());
+ }
+ }
+ for (int id : devices) {
+ HIDDevice device = mDevicesById.get(id);
+ mDevicesById.remove(id);
+ device.shutdown();
+ HIDDeviceDisconnected(id);
+ }
+ }
+
+ private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
+ for (HIDDevice device : mDevicesById.values()) {
+ if (usbDevice.equals(device.getDevice())) {
+ boolean opened = false;
+ if (permission_granted) {
+ opened = device.open();
+ }
+ HIDDeviceOpenResult(device.getId(), opened);
+ }
+ }
+ }
+
+ private void connectHIDDeviceUSB(UsbDevice usbDevice) {
+ synchronized (this) {
+ int interface_mask = 0;
+ for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
+ UsbInterface usbInterface = usbDevice.getInterface(interface_index);
+ if (isHIDDeviceInterface(usbDevice, usbInterface)) {
+ // Check to see if we've already added this interface
+ // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
+ int interface_id = usbInterface.getId();
+ if ((interface_mask & (1 << interface_id)) != 0) {
+ continue;
+ }
+ interface_mask |= (1 << interface_id);
+
+ HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
+ int id = device.getId();
+ mDevicesById.put(id, device);
+ HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false, 0);
+ }
+ }
+ }
+ }
+
+ private void initializeBluetooth() {
+ Log.d(TAG, "Initializing Bluetooth");
+
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ &&
+ mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT");
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ &&
+ mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
+ return;
+ }
+
+ if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
+ return;
+ }
+
+ // Find bonded bluetooth controllers and create SteamControllers for them
+ mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (mBluetoothManager == null) {
+ // This device doesn't support Bluetooth.
+ return;
+ }
+
+ BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
+ if (btAdapter == null) {
+ // This device has Bluetooth support in the codebase, but has no available adapters.
+ return;
+ }
+
+ // Get our bonded devices.
+ for (BluetoothDevice device : btAdapter.getBondedDevices()) {
+
+ Log.d(TAG, "Bluetooth device available: " + device);
+ if (isSteamController(device)) {
+ connectBluetoothDevice(device);
+ }
+
+ }
+
+ // NOTE: These don't work on Chromebooks, to my undying dismay.
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
+ filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
+ if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */
+ mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED);
+ } else {
+ mContext.registerReceiver(mBluetoothBroadcast, filter);
+ }
+
+ if (mIsChromebook) {
+ mHandler = new Handler(Looper.getMainLooper());
+ mLastBluetoothDevices = new ArrayList();
+
+ // final HIDDeviceManager finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // public void run() {
+ // finalThis.chromebookConnectionHandler();
+ // }
+ // }, 5000);
+ }
+ }
+
+ private void shutdownBluetooth() {
+ try {
+ mContext.unregisterReceiver(mBluetoothBroadcast);
+ } catch (Exception e) {
+ // We may not have registered, that's okay
+ }
+ }
+
+ // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
+ // This function provides a sort of dummy version of that, watching for changes in the
+ // connected devices and attempting to add controllers as things change.
+ void chromebookConnectionHandler() {
+ if (!mIsChromebook) {
+ return;
+ }
+
+ ArrayList disconnected = new ArrayList();
+ ArrayList connected = new ArrayList();
+
+ List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
+
+ for (BluetoothDevice bluetoothDevice : currentConnected) {
+ if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
+ connected.add(bluetoothDevice);
+ }
+ }
+ for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
+ if (!currentConnected.contains(bluetoothDevice)) {
+ disconnected.add(bluetoothDevice);
+ }
+ }
+
+ mLastBluetoothDevices = currentConnected;
+
+ for (BluetoothDevice bluetoothDevice : disconnected) {
+ disconnectBluetoothDevice(bluetoothDevice);
+ }
+ for (BluetoothDevice bluetoothDevice : connected) {
+ connectBluetoothDevice(bluetoothDevice);
+ }
+
+ final HIDDeviceManager finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.chromebookConnectionHandler();
+ }
+ }, 10000);
+ }
+
+ boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+ Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
+ synchronized (this) {
+ if (mBluetoothDevices.containsKey(bluetoothDevice)) {
+ Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
+
+ HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+ device.reconnect();
+
+ return false;
+ }
+ HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
+ int id = device.getId();
+ mBluetoothDevices.put(bluetoothDevice, device);
+ mDevicesById.put(id, device);
+
+ // The Steam Controller will mark itself connected once initialization is complete
+ }
+ return true;
+ }
+
+ void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+ synchronized (this) {
+ HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+ if (device == null)
+ return;
+
+ int id = device.getId();
+ mBluetoothDevices.remove(bluetoothDevice);
+ mDevicesById.remove(id);
+ device.shutdown();
+ HIDDeviceDisconnected(id);
+ }
+ }
+
+ boolean isSteamController(BluetoothDevice bluetoothDevice) {
+ // Sanity check. If you pass in a null device, by definition it is never a Steam Controller.
+ if (bluetoothDevice == null) {
+ return false;
+ }
+
+ // If the device has no local name, we really don't want to try an equality check against it.
+ if (bluetoothDevice.getName() == null) {
+ return false;
+ }
+
+ // Steam Controllers will always support Bluetooth Low Energy
+ if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) {
+ return false;
+ }
+
+ // Match on the name either the original Steam Controller or the new second-generation one advertise with.
+ return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl");
+ }
+
+ private void close() {
+ shutdownUSB();
+ shutdownBluetooth();
+ synchronized (this) {
+ for (HIDDevice device : mDevicesById.values()) {
+ device.shutdown();
+ }
+ mDevicesById.clear();
+ mBluetoothDevices.clear();
+ HIDDeviceReleaseCallback();
+ }
+ }
+
+ public void setFrozen(boolean frozen) {
+ synchronized (this) {
+ for (HIDDevice device : mDevicesById.values()) {
+ device.setFrozen(frozen);
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private HIDDevice getDevice(int id) {
+ synchronized (this) {
+ HIDDevice result = mDevicesById.get(id);
+ if (result == null) {
+ Log.v(TAG, "No device for id: " + id);
+ Log.v(TAG, "Available devices: " + mDevicesById.keySet());
+ }
+ return result;
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////// JNI interface functions
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ boolean initialize(boolean usb, boolean bluetooth) {
+ Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
+
+ if (usb) {
+ initializeUSB();
+ }
+ if (bluetooth) {
+ initializeBluetooth();
+ }
+ return true;
+ }
+
+ boolean openDevice(int deviceID) {
+ Log.v(TAG, "openDevice deviceID=" + deviceID);
+ HIDDevice device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return false;
+ }
+
+ // Look to see if this is a USB device and we have permission to access it
+ UsbDevice usbDevice = device.getDevice();
+ if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
+ HIDDeviceOpenPending(deviceID);
+ try {
+ final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
+ int flags;
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
+ flags = FLAG_MUTABLE;
+ } else {
+ flags = 0;
+ }
+
+ Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION);
+ intent.setPackage(mContext.getPackageName());
+ mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags));
+ } catch (Exception e) {
+ Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
+ HIDDeviceOpenResult(deviceID, false);
+ }
+ return false;
+ }
+
+ try {
+ return device.open();
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return false;
+ }
+
+ int writeReport(int deviceID, byte[] report, boolean feature) {
+ try {
+ //Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return -1;
+ }
+
+ return device.writeReport(report, feature);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return -1;
+ }
+
+ boolean readReport(int deviceID, byte[] report, boolean feature) {
+ try {
+ //Log.v(TAG, "readReport deviceID=" + deviceID);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return false;
+ }
+
+ return device.readReport(report, feature);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return false;
+ }
+
+ void closeDevice(int deviceID) {
+ try {
+ Log.v(TAG, "closeDevice deviceID=" + deviceID);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return;
+ }
+
+ device.close();
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ }
+
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ /////////////// Native methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private native void HIDDeviceRegisterCallback();
+ private native void HIDDeviceReleaseCallback();
+
+ native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth, int reportID);
+ native void HIDDeviceOpenPending(int deviceID);
+ native void HIDDeviceOpenResult(int deviceID, boolean opened);
+ native void HIDDeviceDisconnected(int deviceID);
+
+ native void HIDDeviceInputReport(int deviceID, byte[] report);
+ native void HIDDeviceReportResponse(int deviceID, byte[] report);
+}
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
new file mode 100644
index 0000000..08875d2
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
@@ -0,0 +1,354 @@
+package org.libsdl.app;
+
+import android.hardware.usb.*;
+import android.os.Build;
+import android.util.Log;
+import java.util.Arrays;
+import java.util.Locale;
+
+class HIDDeviceUSB implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+
+ protected HIDDeviceManager mManager;
+ protected UsbDevice mDevice;
+ protected int mInterfaceIndex;
+ protected int mInterface;
+ protected int mDeviceId;
+ protected UsbDeviceConnection mConnection;
+ protected UsbEndpoint mInputEndpoint;
+ protected UsbEndpoint mOutputEndpoint;
+ protected InputThread mInputThread;
+ protected boolean mRunning;
+ protected boolean mFrozen;
+ protected boolean mClaimed;
+
+ public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
+ mManager = manager;
+ mDevice = usbDevice;
+ mInterfaceIndex = interface_index;
+ mInterface = mDevice.getInterface(mInterfaceIndex).getId();
+ mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
+ mRunning = false;
+ mClaimed = false;
+ }
+
+ String getIdentifier() {
+ return String.format(Locale.ENGLISH, "%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
+ }
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ return mDevice.getVendorId();
+ }
+
+ @Override
+ public int getProductId() {
+ return mDevice.getProductId();
+ }
+
+ @Override
+ public String getSerialNumber() {
+ String result = null;
+ try {
+ result = mDevice.getSerialNumber();
+ }
+ catch (Exception exception) {
+ //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
+ }
+ if (result == null) {
+ result = "";
+ }
+ return result;
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ String result;
+ result = mDevice.getManufacturerName();
+ if (result == null) {
+ result = String.format("%x", getVendorId());
+ }
+ return result;
+ }
+
+ @Override
+ public String getProductName() {
+ String result;
+ result = mDevice.getProductName();
+ if (result == null) {
+ result = String.format("%x", getProductId());
+ }
+ return result;
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return mDevice;
+ }
+
+ String getDeviceName() {
+ return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
+ }
+
+ @Override
+ public boolean open() {
+ mConnection = mManager.getUSBManager().openDevice(mDevice);
+ if (mConnection == null) {
+ Log.w(TAG, "Unable to open USB device " + getDeviceName());
+ return false;
+ }
+
+ // Force claim our interface
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ if (!mConnection.claimInterface(iface, true)) {
+ Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
+ close();
+ return false;
+ }
+ mClaimed = true;
+
+ // Find the endpoints
+ for (int j = 0; j < iface.getEndpointCount(); j++) {
+ UsbEndpoint endpt = iface.getEndpoint(j);
+ switch (endpt.getDirection()) {
+ case UsbConstants.USB_DIR_IN:
+ if (mInputEndpoint == null) {
+ mInputEndpoint = endpt;
+ }
+ break;
+ case UsbConstants.USB_DIR_OUT:
+ if (mOutputEndpoint == null) {
+ mOutputEndpoint = endpt;
+ }
+ break;
+ }
+ }
+
+ // Make sure the required endpoints were present. The original Steam Controller and the wireless dongle for it do NOT
+ // actually have -- or require -- output endpoints, so we need to accept only an input one for them or else we'll fall
+ // back to the Android system gamepad functionality (and lose our paddles et al).
+ if (mInputEndpoint == null) {
+ Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
+ mConnection.releaseInterface(iface);
+ close();
+ return false;
+ }
+
+ // Start listening for input
+ mRunning = true;
+ mInputThread = new InputThread();
+ mInputThread.start();
+
+ return true;
+ }
+
+ @Override
+ public int writeReport(byte[] report, boolean feature) {
+ if (mConnection == null) {
+ Log.w(TAG, "writeReport() called with no device connection");
+ return -1;
+ }
+
+ if (!mClaimed) {
+ Log.w(TAG, "writeReport() called but some other process currently owns the USB device");
+ return -1;
+ }
+
+ if (feature) {
+ int res = -1;
+ int offset = 0;
+ int length = report.length;
+ boolean skipped_report_id = false;
+ byte report_number = report[0];
+
+ if (report_number == 0x0) {
+ ++offset;
+ --length;
+ skipped_report_id = true;
+ }
+
+ res = mConnection.controlTransfer(
+ UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
+ 0x09/*HID set_report*/,
+ (3/*HID feature*/ << 8) | report_number,
+ mInterface,
+ report, offset, length,
+ 1000/*timeout millis*/);
+
+ if (res < 0) {
+ Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName());
+ return -1;
+ }
+
+ if (skipped_report_id) {
+ ++length;
+ }
+ return length;
+ } else {
+ if (mOutputEndpoint == null)
+ {
+ Log.e(TAG, "Tried to write an output report to an interface with no output endpoint!");
+ return -1;
+ }
+ int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
+ if (res != report.length) {
+ Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName());
+ }
+ return res;
+ }
+ }
+
+ @Override
+ public boolean readReport(byte[] report, boolean feature) {
+ int res = -1;
+ int offset = 0;
+ int length = report.length;
+ boolean skipped_report_id = false;
+ byte report_number = report[0];
+
+ if (mConnection == null) {
+ Log.w(TAG, "readReport() called with no device connection");
+ return false;
+ }
+ if (!mClaimed) {
+ if (feature) {
+ return false;
+ }
+ return true;
+ }
+
+ if (report_number == 0x0) {
+ /* Offset the return buffer by 1, so that the report ID
+ will remain in byte 0. */
+ ++offset;
+ --length;
+ skipped_report_id = true;
+ }
+
+ res = mConnection.controlTransfer(
+ UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
+ 0x01/*HID get_report*/,
+ ((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number,
+ mInterface,
+ report, offset, length,
+ 1000/*timeout millis*/);
+
+ if (res < 0) {
+ Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
+ return false;
+ }
+
+ if (skipped_report_id) {
+ ++res;
+ ++length;
+ }
+
+ byte[] data;
+ if (res == length) {
+ data = report;
+ } else {
+ data = Arrays.copyOfRange(report, 0, res);
+ }
+ mManager.HIDDeviceReportResponse(mDeviceId, data);
+
+ return true;
+ }
+
+ @Override
+ public void close() {
+ mRunning = false;
+ if (mInputThread != null) {
+ while (mInputThread.isAlive()) {
+ mInputThread.interrupt();
+ try {
+ mInputThread.join();
+ } catch (InterruptedException e) {
+ // Keep trying until we're done
+ }
+ }
+ mInputThread = null;
+ }
+ if (mConnection != null) {
+ if (mClaimed) {
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ mConnection.releaseInterface(iface);
+ }
+ mConnection.close();
+ mConnection = null;
+ mClaimed = false;
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+ mManager = null;
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+
+ /* If we have a valid device connection and the claim state doesn't match what we want, try to correct that. */
+ if (mConnection != null && mClaimed == mFrozen) {
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ if (frozen) {
+ mClaimed = !mConnection.releaseInterface(iface);
+ if (mClaimed) {
+ Log.e(TAG, "Tried to release claim on USB device, but failed!");
+ }
+ } else {
+ mClaimed = mConnection.claimInterface(iface, true);
+ if (!mClaimed) {
+ Log.e(TAG, "Tried to regain claim on USB device, but failed!");
+ }
+ }
+ }
+ }
+
+ protected class InputThread extends Thread {
+ @Override
+ public void run() {
+ int packetSize = mInputEndpoint.getMaxPacketSize();
+ byte[] packet = new byte[packetSize];
+ while (mRunning) {
+ int r;
+ try
+ {
+ r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
+ }
+ catch (Exception e)
+ {
+ Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
+ break;
+ }
+ if (r < 0) {
+ // Could be a timeout or an I/O error
+ }
+ if (r > 0) {
+ byte[] data;
+ if (r == packetSize) {
+ data = packet;
+ } else {
+ data = Arrays.copyOfRange(packet, 0, r);
+ }
+
+ if (!mFrozen) {
+ mManager.HIDDeviceInputReport(mDeviceId, data);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDL.java b/android-project/app/src/main/java/org/libsdl/app/SDL.java
new file mode 100644
index 0000000..097eb8c
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/SDL.java
@@ -0,0 +1,122 @@
+package org.libsdl.app;
+
+import android.app.Activity;
+import android.content.Context;
+
+import java.lang.reflect.Method;
+
+/**
+ SDL library initialization
+*/
+public class SDL {
+
+ // SDL_INIT_* values, mirrored so embedders can request a subset and skip stub classes for unused subsystems.
+ public static final int SDL_INIT_AUDIO = 0x00000010;
+ public static final int SDL_INIT_VIDEO = 0x00000020;
+ public static final int SDL_INIT_JOYSTICK = 0x00000200;
+ public static final int SDL_INIT_HAPTIC = 0x00001000;
+ public static final int SDL_INIT_GAMEPAD = 0x00002000;
+ public static final int SDL_INIT_SENSOR = 0x00008000;
+ public static final int SDL_INIT_CAMERA = 0x00010000;
+
+ public static final int SDL_INIT_EVERYTHING = SDL_INIT_AUDIO | SDL_INIT_VIDEO |
+ SDL_INIT_JOYSTICK | SDL_INIT_HAPTIC | SDL_INIT_GAMEPAD | SDL_INIT_SENSOR | SDL_INIT_CAMERA;
+
+ private static int mInitializedSubsystems = SDL_INIT_EVERYTHING;
+
+ static public void setupJNI() {
+ setupJNI(SDL_INIT_EVERYTHING);
+ }
+
+ // Mask must match the native build's SDL_*_DISABLED flags: dropping SDL_INIT_AUDIO when the lib was built with audio stalls checkJNIReady() and SDL_SetMainReady() never fires.
+ static public void setupJNI(int subsystems) {
+ mInitializedSubsystems = subsystems;
+
+ SDLActivity.nativeSetupJNI();
+
+ if ((subsystems & SDL_INIT_AUDIO) != 0) {
+ SDLAudioManager.nativeSetupJNI();
+ }
+
+ SDLControllerManager.nativeSetupJNI();
+ }
+
+ static public void initialize() {
+ initialize(mInitializedSubsystems);
+ }
+
+ static public void initialize(int subsystems) {
+ setContext(null);
+
+ SDLActivity.initialize();
+
+ if ((subsystems & SDL_INIT_AUDIO) != 0) {
+ SDLAudioManager.initialize();
+ }
+
+ SDLControllerManager.initialize();
+ }
+
+ // This function stores the current activity (SDL or not)
+ static public void setContext(Activity context) {
+ if ((mInitializedSubsystems & SDL_INIT_AUDIO) != 0) {
+ SDLAudioManager.setContext(context);
+ }
+ mContext = context;
+ }
+
+ static public Activity getContext() {
+ return mContext;
+ }
+
+ static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
+ loadLibrary(libraryName, mContext);
+ }
+
+ static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
+
+ if (libraryName == null) {
+ throw new NullPointerException("No library name provided.");
+ }
+
+ try {
+ // Let's see if we have ReLinker available in the project. This is necessary for
+ // some projects that have huge numbers of local libraries bundled, and thus may
+ // trip a bug in Android's native library loader which ReLinker works around. (If
+ // loadLibrary works properly, ReLinker will simply use the normal Android method
+ // internally.)
+ //
+ // To use ReLinker, just add it as a dependency. For more information, see
+ // https://github.com/KeepSafe/ReLinker for ReLinker's repository.
+ //
+ Class> relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
+ Class> relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
+ Class> contextClass = context.getClassLoader().loadClass("android.content.Context");
+ Class> stringClass = context.getClassLoader().loadClass("java.lang.String");
+
+ // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if
+ // they've changed during updates.
+ Method forceMethod = relinkClass.getDeclaredMethod("force");
+ Object relinkInstance = forceMethod.invoke(null);
+ Class> relinkInstanceClass = relinkInstance.getClass();
+
+ // Actually load the library!
+ Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
+ loadMethod.invoke(relinkInstance, context, libraryName, null, null);
+ }
+ catch (final Throwable e) {
+ // Fall back
+ try {
+ System.loadLibrary(libraryName);
+ }
+ catch (final UnsatisfiedLinkError ule) {
+ throw ule;
+ }
+ catch (final SecurityException se) {
+ throw se;
+ }
+ }
+ }
+
+ protected static Activity mContext;
+}
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
new file mode 100644
index 0000000..00df5f0
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
@@ -0,0 +1,2414 @@
+package org.libsdl.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.UiModeManager;
+import android.content.ActivityNotFoundException;
+import android.content.ClipboardManager;
+import android.content.ClipData;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.hardware.Sensor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.LocaleList;
+import android.os.Message;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowInsetsController;
+import android.view.WindowManager;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import android.webkit.MimeTypeMap;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.Hashtable;
+import java.util.Locale;
+
+
+/**
+ SDL Activity
+*/
+public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener {
+ private static final String TAG = "SDL";
+ private static final int SDL_MAJOR_VERSION = 3;
+ private static final int SDL_MINOR_VERSION = 5;
+ private static final int SDL_MICRO_VERSION = 0;
+/*
+ // Display InputType.SOURCE/CLASS of events and devices
+ //
+ // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]");
+ // SDLActivity.debugSource(event.getSource(), "event");
+ public static void debugSource(int sources, String prefix) {
+ int s = sources;
+ int s_copy = sources;
+ String cls = "";
+ String src = "";
+ int tst = 0;
+ int FLAG_TAINTED = 0x80000000;
+
+ if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON";
+ if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK";
+ if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER";
+ if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION";
+ if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL";
+
+
+ int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits
+ s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON
+ | InputDevice.SOURCE_CLASS_JOYSTICK
+ | InputDevice.SOURCE_CLASS_POINTER
+ | InputDevice.SOURCE_CLASS_POSITION
+ | InputDevice.SOURCE_CLASS_TRACKBALL);
+
+ if (s2 != 0) cls += "Some_Unknown";
+
+ s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class;
+
+ if (Build.VERSION.SDK_INT >= 23) {
+ tst = InputDevice.SOURCE_BLUETOOTH_STYLUS;
+ if ((s & tst) == tst) src += " BLUETOOTH_STYLUS";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_DPAD;
+ if ((s & tst) == tst) src += " DPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_GAMEPAD;
+ if ((s & tst) == tst) src += " GAMEPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_HDMI;
+ if ((s & tst) == tst) src += " HDMI";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_JOYSTICK;
+ if ((s & tst) == tst) src += " JOYSTICK";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_KEYBOARD;
+ if ((s & tst) == tst) src += " KEYBOARD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_MOUSE;
+ if ((s & tst) == tst) src += " MOUSE";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ tst = InputDevice.SOURCE_MOUSE_RELATIVE;
+ if ((s & tst) == tst) src += " MOUSE_RELATIVE";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_ROTARY_ENCODER;
+ if ((s & tst) == tst) src += " ROTARY_ENCODER";
+ s2 &= ~tst;
+ }
+ tst = InputDevice.SOURCE_STYLUS;
+ if ((s & tst) == tst) src += " STYLUS";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCHPAD;
+ if ((s & tst) == tst) src += " TOUCHPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCHSCREEN;
+ if ((s & tst) == tst) src += " TOUCHSCREEN";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCH_NAVIGATION;
+ if ((s & tst) == tst) src += " TOUCH_NAVIGATION";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TRACKBALL;
+ if ((s & tst) == tst) src += " TRACKBALL";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_ANY;
+ if ((s & tst) == tst) src += " ANY";
+ s2 &= ~tst;
+
+ if (s == FLAG_TAINTED) src += " FLAG_TAINTED";
+ s2 &= ~FLAG_TAINTED;
+
+ if (s2 != 0) src += " Some_Unknown";
+
+ Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src);
+ }
+*/
+
+ public static boolean mIsResumedCalled, mHasFocus;
+ public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */);
+
+ // Cursor types
+ // private static final int SDL_SYSTEM_CURSOR_NONE = -1;
+ private static final int SDL_SYSTEM_CURSOR_DEFAULT = 0;
+ private static final int SDL_SYSTEM_CURSOR_TEXT = 1;
+ private static final int SDL_SYSTEM_CURSOR_WAIT = 2;
+ private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3;
+ private static final int SDL_SYSTEM_CURSOR_PROGRESS = 4;
+ private static final int SDL_SYSTEM_CURSOR_NWSE_RESIZE = 5;
+ private static final int SDL_SYSTEM_CURSOR_NESW_RESIZE = 6;
+ private static final int SDL_SYSTEM_CURSOR_EW_RESIZE = 7;
+ private static final int SDL_SYSTEM_CURSOR_NS_RESIZE = 8;
+ private static final int SDL_SYSTEM_CURSOR_MOVE = 9;
+ private static final int SDL_SYSTEM_CURSOR_NOT_ALLOWED = 10;
+ private static final int SDL_SYSTEM_CURSOR_POINTER = 11;
+ private static final int SDL_SYSTEM_CURSOR_NW_RESIZE = 12;
+ private static final int SDL_SYSTEM_CURSOR_N_RESIZE = 13;
+ private static final int SDL_SYSTEM_CURSOR_NE_RESIZE = 14;
+ private static final int SDL_SYSTEM_CURSOR_E_RESIZE = 15;
+ private static final int SDL_SYSTEM_CURSOR_SE_RESIZE = 16;
+ private static final int SDL_SYSTEM_CURSOR_S_RESIZE = 17;
+ private static final int SDL_SYSTEM_CURSOR_SW_RESIZE = 18;
+ private static final int SDL_SYSTEM_CURSOR_W_RESIZE = 19;
+ private static final int SDL_SYSTEM_CURSOR_CONTEXT_MENU = 20;
+ private static final int SDL_SYSTEM_CURSOR_HELP = 21;
+ private static final int SDL_SYSTEM_CURSOR_CELL = 22;
+ private static final int SDL_SYSTEM_CURSOR_VERTICAL_TEXT = 23;
+ private static final int SDL_SYSTEM_CURSOR_ALIAS = 24;
+ private static final int SDL_SYSTEM_CURSOR_COPY = 25;
+ private static final int SDL_SYSTEM_CURSOR_NO_DROP = 26;
+ private static final int SDL_SYSTEM_CURSOR_GRAB = 27;
+ private static final int SDL_SYSTEM_CURSOR_GRABBING = 28;
+ private static final int SDL_SYSTEM_CURSOR_COL_RESIZE = 29;
+ private static final int SDL_SYSTEM_CURSOR_ROW_RESIZE = 30;
+ private static final int SDL_SYSTEM_CURSOR_ALL_SCROLL = 31;
+ private static final int SDL_SYSTEM_CURSOR_ZOOM_IN = 32;
+ private static final int SDL_SYSTEM_CURSOR_ZOOM_OUT = 33;
+
+ protected static final int SDL_ORIENTATION_UNKNOWN = 0;
+ protected static final int SDL_ORIENTATION_LANDSCAPE = 1;
+ protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2;
+ protected static final int SDL_ORIENTATION_PORTRAIT = 3;
+ protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4;
+
+ protected static int mCurrentRotation;
+ protected static Locale mCurrentLocale;
+
+ // Handle the state of the native layer
+ public enum NativeState {
+ INIT, RESUMED, PAUSED
+ }
+
+ public static NativeState mNextNativeState;
+ public static NativeState mCurrentNativeState;
+
+ /** If shared libraries (e.g. SDL or the native application) could not be loaded. */
+ public static boolean mBrokenLibraries = true;
+
+ // Main components
+ protected static SDLActivity mSingleton;
+ protected static SDLSurface mSurface;
+ protected static SDLDummyEdit mTextEdit;
+ protected static ViewGroup mLayout;
+ protected static SDLClipboardHandler mClipboardHandler;
+ protected static Hashtable mCursors;
+ protected static int mLastCursorID;
+ protected static SDLGenericMotionListener_API14 mMotionListener;
+ protected static HIDDeviceManager mHIDDeviceManager;
+
+ // This is what SDL runs in. It invokes SDL_main(), eventually
+ protected static Thread mSDLThread;
+ protected static boolean mSDLMainFinished = false;
+ protected static boolean mActivityCreated = false;
+ private static SDLFileDialogState mFileDialogState = null;
+ protected static boolean mDispatchingKeyEvent = false;
+
+ public static SDLGenericMotionListener_API14 getMotionListener() {
+ if (mMotionListener == null) {
+ if (Build.VERSION.SDK_INT >= 29 /* Android 10 (Q) */) {
+ mMotionListener = new SDLGenericMotionListener_API29();
+ } else if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
+ mMotionListener = new SDLGenericMotionListener_API26();
+ } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ mMotionListener = new SDLGenericMotionListener_API24();
+ } else {
+ mMotionListener = new SDLGenericMotionListener_API14();
+ }
+ }
+
+ return mMotionListener;
+ }
+
+ /**
+ * The application entry point, called on a dedicated thread (SDLThread).
+ * The default implementation uses the getMainSharedObject() and getMainFunction() methods
+ * to invoke native code from the specified shared library.
+ * It can be overridden by derived classes.
+ */
+ protected void main() {
+ String library = SDLActivity.mSingleton.getMainSharedObject();
+ String function = SDLActivity.mSingleton.getMainFunction();
+ String[] arguments = SDLActivity.mSingleton.getArguments();
+
+ Log.v("SDL", "Running main function " + function + " from library " + library);
+ SDLActivity.nativeRunMain(library, function, arguments);
+ Log.v("SDL", "Finished main function");
+ }
+
+ /**
+ * This method returns the name of the shared object with the application entry point
+ * It can be overridden by derived classes.
+ */
+ protected String getMainSharedObject() {
+ String library;
+ String[] libraries = SDLActivity.mSingleton.getLibraries();
+ if (libraries.length > 0) {
+ library = "lib" + libraries[libraries.length - 1] + ".so";
+ } else {
+ library = "libmain.so";
+ }
+ return getContext().getApplicationInfo().nativeLibraryDir + "/" + library;
+ }
+
+ /**
+ * This method returns the name of the application entry point
+ * It can be overridden by derived classes.
+ */
+ protected String getMainFunction() {
+ return "SDL_main";
+ }
+
+ /**
+ * This method is called by SDL before loading the native shared libraries.
+ * It can be overridden to provide names of shared libraries to be loaded.
+ * The default implementation returns the defaults. It never returns null.
+ * An array returned by a new implementation must at least contain "SDL3".
+ * Also keep in mind that the order the libraries are loaded may matter.
+ * @return names of shared libraries to be loaded (e.g. "SDL3", "main").
+ */
+ protected String[] getLibraries() {
+ return new String[] {
+ "SDL3",
+ // "SDL3_image",
+ // "SDL3_mixer",
+ // "SDL3_net",
+ // "SDL3_ttf",
+ "main"
+ };
+ }
+
+ // Load the .so
+ public void loadLibraries() {
+ for (String lib : getLibraries()) {
+ SDL.loadLibrary(lib, this);
+ }
+ }
+
+ /**
+ * This method is called by SDL before starting the native application thread.
+ * It can be overridden to provide the arguments after the application name.
+ * The default implementation returns an empty array. It never returns null.
+ * @return arguments for the native application.
+ */
+ protected String[] getArguments() {
+ return new String[0];
+ }
+
+ public static void initialize() {
+ // The static nature of the singleton and Android quirkyness force us to initialize everything here
+ // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values
+ mSingleton = null;
+ mSurface = null;
+ mTextEdit = null;
+ mLayout = null;
+ mClipboardHandler = null;
+ mCursors = new Hashtable();
+ mLastCursorID = 0;
+ mSDLThread = null;
+ mIsResumedCalled = false;
+ mHasFocus = true;
+ mNextNativeState = NativeState.INIT;
+ mCurrentNativeState = NativeState.INIT;
+ }
+
+ protected SDLSurface createSDLSurface(Context context) {
+ return new SDLSurface(context);
+ }
+
+ // Setup
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER);
+ Log.v(TAG, "Device: " + Build.DEVICE);
+ Log.v(TAG, "Model: " + Build.MODEL);
+ Log.v(TAG, "onCreate()");
+ super.onCreate(savedInstanceState);
+
+
+ /* Control activity re-creation */
+ if (mSDLMainFinished || mActivityCreated) {
+ boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity();
+ if (mSDLMainFinished) {
+ Log.v(TAG, "SDL main() finished");
+ }
+ if (allow_recreate) {
+ Log.v(TAG, "activity re-created");
+ } else {
+ Log.v(TAG, "activity finished");
+ System.exit(0);
+ return;
+ }
+ }
+
+ mActivityCreated = true;
+
+ try {
+ Thread.currentThread().setName("SDLActivity");
+ } catch (Exception e) {
+ Log.v(TAG, "modify thread properties failed " + e.toString());
+ }
+
+ // Load shared libraries
+ String errorMsgBrokenLib = "";
+ try {
+ loadLibraries();
+ mBrokenLibraries = false; /* success */
+ } catch(UnsatisfiedLinkError e) {
+ System.err.println(e.getMessage());
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = e.getMessage();
+ } catch(Exception e) {
+ System.err.println(e.getMessage());
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = e.getMessage();
+ }
+
+ if (!mBrokenLibraries) {
+ String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." +
+ String.valueOf(SDL_MINOR_VERSION) + "." +
+ String.valueOf(SDL_MICRO_VERSION);
+ String version = nativeGetVersion();
+ if (!version.equals(expected_version)) {
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")";
+ }
+ }
+
+ if (mBrokenLibraries) {
+ mSingleton = this;
+ AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this);
+ dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall."
+ + System.getProperty("line.separator")
+ + System.getProperty("line.separator")
+ + "Error: " + errorMsgBrokenLib);
+ dlgAlert.setTitle("SDL Error");
+ dlgAlert.setPositiveButton("Exit",
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog,int id) {
+ // if this button is clicked, close current activity
+ SDLActivity.mSingleton.finish();
+ }
+ });
+ dlgAlert.setCancelable(false);
+ dlgAlert.create().show();
+
+ return;
+ }
+
+
+ /* Control activity re-creation */
+ /* Robustness: check that the native code is run for the first time.
+ * (Maybe Activity was reset, but not the native code.) */
+ {
+ int run_count = SDLActivity.nativeCheckSDLThreadCounter(); /* get and increment a native counter */
+ if (run_count != 0) {
+ boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity();
+ if (allow_recreate) {
+ Log.v(TAG, "activity re-created // run_count: " + run_count);
+ } else {
+ Log.v(TAG, "activity finished // run_count: " + run_count);
+ System.exit(0);
+ return;
+ }
+ }
+ }
+
+ // Set up JNI
+ SDL.setupJNI();
+
+ // Initialize state
+ SDL.initialize();
+
+ // So we can call stuff from static callbacks
+ mSingleton = this;
+ SDL.setContext(this);
+
+ SDLControllerManager.initializeDeviceListener();
+
+ mClipboardHandler = new SDLClipboardHandler();
+
+ mHIDDeviceManager = HIDDeviceManager.acquire(this);
+
+ // Set up the surface
+ mSurface = createSDLSurface(this);
+
+ mLayout = new RelativeLayout(this);
+ mLayout.addView(mSurface);
+
+ // Get our current screen orientation and pass it down.
+ SDLActivity.nativeSetNaturalOrientation(SDLActivity.getNaturalOrientation());
+ mCurrentRotation = SDLActivity.getCurrentRotation();
+ SDLActivity.onNativeRotationChanged(mCurrentRotation);
+
+ try {
+ if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) {
+ mCurrentLocale = getContext().getResources().getConfiguration().locale;
+ } else {
+ mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0);
+ }
+ } catch(Exception ignored) {
+ }
+
+ switch (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) {
+ case Configuration.UI_MODE_NIGHT_NO:
+ SDLActivity.onNativeDarkModeChanged(false);
+ break;
+ case Configuration.UI_MODE_NIGHT_YES:
+ SDLActivity.onNativeDarkModeChanged(true);
+ break;
+ }
+
+ setContentView(mLayout);
+
+ setWindowStyle(false);
+
+ getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this);
+
+ // Get filename from "Open with" of another application
+ Intent intent = getIntent();
+ if (intent != null && intent.getData() != null) {
+ String filename = intent.getData().getPath();
+ if (filename != null) {
+ Log.v(TAG, "Got filename: " + filename);
+ SDLActivity.onNativeDropFile(filename);
+ }
+ }
+ }
+
+ protected void pauseNativeThread() {
+ mNextNativeState = NativeState.PAUSED;
+ mIsResumedCalled = false;
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.handleNativeState();
+ }
+
+ protected void resumeNativeThread() {
+ mNextNativeState = NativeState.RESUMED;
+ mIsResumedCalled = true;
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.handleNativeState();
+ }
+
+ // Events
+ @Override
+ protected void onPause() {
+ Log.v(TAG, "onPause()");
+ super.onPause();
+
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(true);
+ }
+
+ if (!mHasMultiWindow) {
+ pauseNativeThread();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ Log.v(TAG, "onResume()");
+ super.onResume();
+
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(false);
+ }
+
+ if (!mHasMultiWindow) {
+ resumeNativeThread();
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ Log.v(TAG, "onStop()");
+ super.onStop();
+ if (mHasMultiWindow) {
+ pauseNativeThread();
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ Log.v(TAG, "onStart()");
+ super.onStart();
+ if (mHasMultiWindow) {
+ resumeNativeThread();
+ }
+ }
+
+ public static int getNaturalOrientation() {
+ int result = SDL_ORIENTATION_UNKNOWN;
+
+ Activity activity = getContext();
+ if (activity != null) {
+ Configuration config = activity.getResources().getConfiguration();
+ Display display = activity.getWindowManager().getDefaultDisplay();
+ int rotation = display.getRotation();
+ if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) &&
+ config.orientation == Configuration.ORIENTATION_LANDSCAPE) ||
+ ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) &&
+ config.orientation == Configuration.ORIENTATION_PORTRAIT)) {
+ result = SDL_ORIENTATION_LANDSCAPE;
+ } else {
+ result = SDL_ORIENTATION_PORTRAIT;
+ }
+ }
+ return result;
+ }
+
+ public static int getCurrentRotation() {
+ int result = 0;
+
+ Activity activity = getContext();
+ if (activity != null) {
+ Display display = activity.getWindowManager().getDefaultDisplay();
+ switch (display.getRotation()) {
+ case Surface.ROTATION_0:
+ result = 0;
+ break;
+ case Surface.ROTATION_90:
+ result = 90;
+ break;
+ case Surface.ROTATION_180:
+ result = 180;
+ break;
+ case Surface.ROTATION_270:
+ result = 270;
+ break;
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ Log.v(TAG, "onWindowFocusChanged(): " + hasFocus);
+
+ // If we are gaining focus, we can always try to restore our USB devices. If we are losing focus,
+ // only try to relinquish them if we don't have background events allowed (for multi-window Android setups).
+ if (hasFocus || !SDLActivity.nativeGetHintBoolean("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", false)) {
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(!hasFocus);
+ }
+ }
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ mHasFocus = hasFocus;
+ if (hasFocus) {
+ mNextNativeState = NativeState.RESUMED;
+ SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded();
+
+ SDLActivity.handleNativeState();
+ nativeFocusChanged(true);
+
+ } else {
+ nativeFocusChanged(false);
+ if (!mHasMultiWindow) {
+ mNextNativeState = NativeState.PAUSED;
+ SDLActivity.handleNativeState();
+ }
+ }
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ Log.v(TAG, "onTrimMemory()");
+ super.onTrimMemory(level);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.nativeLowMemory();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ Log.v(TAG, "onConfigurationChanged()");
+ super.onConfigurationChanged(newConfig);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) {
+ mCurrentLocale = newConfig.locale;
+ SDLActivity.onNativeLocaleChanged();
+ }
+
+ switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) {
+ case Configuration.UI_MODE_NIGHT_NO:
+ SDLActivity.onNativeDarkModeChanged(false);
+ break;
+ case Configuration.UI_MODE_NIGHT_YES:
+ SDLActivity.onNativeDarkModeChanged(true);
+ break;
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ Log.v(TAG, "onDestroy()");
+
+ if (mHIDDeviceManager != null) {
+ HIDDeviceManager.release(mHIDDeviceManager);
+ mHIDDeviceManager = null;
+ }
+
+ SDLAudioManager.release(this);
+
+ if (SDLActivity.mBrokenLibraries) {
+ super.onDestroy();
+ return;
+ }
+
+ if (SDLActivity.mSDLThread != null) {
+
+ // Send Quit event to "SDLThread" thread
+ SDLActivity.nativeSendQuit();
+
+ // Wait for "SDLThread" thread to end
+ try {
+ // Use a timeout because:
+ // C SDLmain() thread might have started (mSDLThread.start() called)
+ // while the SDL_Init() might not have been called yet,
+ // and so the previous QUIT event will be discarded by SDL_Init() and app is running, not exiting.
+ SDLActivity.mSDLThread.join(1000);
+ } catch(Exception e) {
+ Log.v(TAG, "Problem stopping SDLThread: " + e);
+ }
+ }
+
+ SDLActivity.nativeQuit();
+
+ super.onDestroy();
+ }
+
+ @Override
+ public void onBackPressed() {
+ // Check if we want to block the back button in case of mouse right click.
+ //
+ // If we do, the normal hardware back button will no longer work and people have to use home,
+ // but the mouse right click will work.
+ //
+ boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false);
+ if (trapBack) {
+ // Exit and let the mouse handler handle this button (if appropriate)
+ return;
+ }
+
+ // Default system back button behavior.
+ if (!isFinishing()) {
+ super.onBackPressed();
+ }
+ }
+
+ // File dialog types
+ private static final int SDL_FILEDIALOG_OPENFILE = 0;
+ private static final int SDL_FILEDIALOG_SAVEFILE = 1;
+ private static final int SDL_FILEDIALOG_OPENFOLDER = 2;
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) {
+ /* This is our file dialog */
+ String[] filelist = null;
+
+ if (data != null && resultCode == Activity.RESULT_OK) {
+ Uri singleFileUri = data.getData();
+
+ if (singleFileUri == null) {
+ /* Use Intent.getClipData to get multiple choices */
+ ClipData clipData = data.getClipData();
+ assert clipData != null;
+
+ filelist = new String[clipData.getItemCount()];
+
+ for (int i = 0; i < filelist.length; i++) {
+ String uri = clipData.getItemAt(i).getUri().toString();
+ filelist[i] = uri;
+ }
+ } else {
+ /* If the user selected a directory and the persistent permission hint has been set,
+ make the permission persistable */
+ if (mFileDialogState.type == SDL_FILEDIALOG_OPENFOLDER && mFileDialogState.persistable) {
+ mSingleton.getContentResolver().takePersistableUriPermission(singleFileUri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION |
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ }
+ /* Only one file is selected. */
+ filelist = new String[]{singleFileUri.toString()};
+ }
+ } else {
+ /* User cancelled the request. */
+ filelist = new String[0];
+ }
+
+ // TODO: Detect the file MIME type and pass the filter value accordingly.
+ SDLActivity.onNativeFileDialog(requestCode, filelist, -1);
+ mFileDialogState = null;
+ }
+ }
+
+ // Called by JNI from SDL.
+ public static void manualBackButton() {
+ mSingleton.pressBackButton();
+ }
+
+ // Used to get us onto the activity's main thread
+ public void pressBackButton() {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (!SDLActivity.this.isFinishing()) {
+ SDLActivity.this.superOnBackPressed();
+ }
+ }
+ });
+ }
+
+ // Used to access the system back behavior.
+ public void superOnBackPressed() {
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+
+ if (SDLActivity.mBrokenLibraries) {
+ return false;
+ }
+
+ int keyCode = event.getKeyCode();
+ // Ignore certain special keys so they're handled by Android
+ if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
+ keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
+ keyCode == KeyEvent.KEYCODE_CAMERA ||
+ keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */
+ keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */
+ ) {
+ return false;
+ }
+ mDispatchingKeyEvent = true;
+ boolean result = super.dispatchKeyEvent(event);
+ mDispatchingKeyEvent = false;
+ return result;
+ }
+
+ public static boolean dispatchingKeyEvent() {
+ return mDispatchingKeyEvent;
+ }
+
+ /* Transition to next state */
+ public static void handleNativeState() {
+
+ if (mNextNativeState == mCurrentNativeState) {
+ // Already in same state, discard.
+ return;
+ }
+
+ // Try a transition to init state
+ if (mNextNativeState == NativeState.INIT) {
+
+ mCurrentNativeState = mNextNativeState;
+ return;
+ }
+
+ // Try a transition to paused state
+ if (mNextNativeState == NativeState.PAUSED) {
+ if (mSDLThread != null) {
+ nativePause();
+ }
+ if (mSurface != null) {
+ mSurface.handlePause();
+ }
+ mCurrentNativeState = mNextNativeState;
+ return;
+ }
+
+ // Try a transition to resumed state
+ if (mNextNativeState == NativeState.RESUMED) {
+ if (mSurface.mIsSurfaceReady && (mHasFocus || mHasMultiWindow) && mIsResumedCalled) {
+ if (mSDLThread == null) {
+ // This is the entry point to the C app.
+ // Start up the C app thread and enable sensor input for the first time
+ // FIXME: Why aren't we enabling sensor input at start?
+
+ mSDLThread = new Thread(new SDLMain(), "SDLThread");
+ mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+ mSDLThread.start();
+
+ // No nativeResume(), don't signal Android_ResumeSem
+ } else {
+ nativeResume();
+ }
+ mSurface.handleResume();
+
+ mCurrentNativeState = mNextNativeState;
+ }
+ }
+ }
+
+ // Messages from the SDLMain thread
+ protected static final int COMMAND_CHANGE_TITLE = 1;
+ protected static final int COMMAND_CHANGE_WINDOW_STYLE = 2;
+ protected static final int COMMAND_TEXTEDIT_HIDE = 3;
+ protected static final int COMMAND_SET_KEEP_SCREEN_ON = 5;
+ protected static final int COMMAND_USER = 0x8000;
+
+ protected static boolean mFullscreenModeActive;
+
+ /**
+ * This method is called by SDL if SDL did not handle a message itself.
+ * This happens if a received message contains an unsupported command.
+ * Method can be overwritten to handle Messages in a different class.
+ * @param command the command of the message.
+ * @param param the parameter of the message. May be null.
+ * @return if the message was handled in overridden method.
+ */
+ protected boolean onUnhandledMessage(int command, Object param) {
+ return false;
+ }
+
+ /**
+ * A Handler class for Messages from native SDL applications.
+ * It uses current Activities as target (e.g. for the title).
+ * static to prevent implicit references to enclosing object.
+ */
+ protected static class SDLCommandHandler extends Handler {
+ @Override
+ public void handleMessage(Message msg) {
+ Context context = getContext();
+ if (context == null) {
+ Log.e(TAG, "error handling message, getContext() returned null");
+ return;
+ }
+ switch (msg.arg1) {
+ case COMMAND_CHANGE_TITLE:
+ if (context instanceof Activity) {
+ ((Activity) context).setTitle((String)msg.obj);
+ } else {
+ Log.e(TAG, "error handling message, getContext() returned no Activity");
+ }
+ break;
+ case COMMAND_CHANGE_WINDOW_STYLE:
+ if (context instanceof Activity) {
+ Window window = ((Activity) context).getWindow();
+ if (window != null) {
+ if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+ if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) {
+ // The legacy setSystemUiVisibility() flags are ignored on
+ // Android 15+ (API 35+), where edge-to-edge is enforced for
+ // apps targeting that SDK, so the status/navigation bars
+ // would never hide. Use WindowInsetsController instead.
+ window.setDecorFitsSystemWindows(false);
+ final WindowInsetsController controller = window.getInsetsController();
+ if (controller != null) {
+ controller.hide(WindowInsets.Type.systemBars());
+ // Sticky-immersive (replaces SYSTEM_UI_FLAG_IMMERSIVE_STICKY):
+ // a swipe shows the bars transiently, then they auto-hide.
+ controller.setSystemBarsBehavior(
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ }
+ } else {
+ int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+ window.getDecorView().setSystemUiVisibility(flags);
+ window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+ }
+ SDLActivity.mFullscreenModeActive = true;
+ } else {
+ int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE;
+ window.getDecorView().setSystemUiVisibility(flags);
+ window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+ window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ SDLActivity.mFullscreenModeActive = false;
+ }
+ if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) {
+ window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
+ }
+ if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */ &&
+ Build.VERSION.SDK_INT < 35 /* Android 15 */) {
+ SDLActivity.onNativeInsetsChanged(0, 0, 0, 0);
+ }
+ }
+ } else {
+ Log.e(TAG, "error handling message, getContext() returned no Activity");
+ }
+ break;
+ case COMMAND_TEXTEDIT_HIDE:
+ if (mTextEdit != null) {
+ // Note: On some devices setting view to GONE creates a flicker in landscape.
+ // Setting the View's sizes to 0 is similar to GONE but without the flicker.
+ // The sizes will be set to useful values when the keyboard is shown again.
+ mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0));
+
+ InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0);
+
+ onNativeScreenKeyboardHidden();
+
+ mSurface.requestFocus();
+ }
+ break;
+ case COMMAND_SET_KEEP_SCREEN_ON:
+ {
+ if (context instanceof Activity) {
+ Window window = ((Activity) context).getWindow();
+ if (window != null) {
+ if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ }
+ }
+ break;
+ }
+ default:
+ if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) {
+ Log.e(TAG, "error handling message, command is " + msg.arg1);
+ }
+ }
+ }
+ }
+
+ // Handler for the messages
+ Handler commandHandler = new SDLCommandHandler();
+
+ // Send a message from the SDLMain thread
+ protected boolean sendCommand(int command, Object data) {
+ Message msg = commandHandler.obtainMessage();
+ msg.arg1 = command;
+ msg.obj = data;
+ boolean result = commandHandler.sendMessage(msg);
+
+ if (command == COMMAND_CHANGE_WINDOW_STYLE) {
+ // Ensure we don't return until the resize has actually happened,
+ // or 500ms have passed.
+
+ boolean bShouldWait = false;
+
+ if (data instanceof Integer) {
+ // Let's figure out if we're already laid out fullscreen or not.
+ Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+ DisplayMetrics realMetrics = new DisplayMetrics();
+ display.getRealMetrics(realMetrics);
+
+ boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) &&
+ (realMetrics.heightPixels == mSurface.getHeight()));
+
+ if ((Integer) data == 1) {
+ // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going
+ // to change size and should wait for surfaceChanged() before we return, so the size
+ // is right back in native code. If we're already laid out fullscreen, though, we're
+ // not going to change size even if we change decor modes, so we shouldn't wait for
+ // surfaceChanged() -- which may not even happen -- and should return immediately.
+ bShouldWait = !bFullscreenLayout;
+ } else {
+ // If we're laid out fullscreen (even if the status bar and nav bar are present),
+ // or are actively in fullscreen, we're going to change size and should wait for
+ // surfaceChanged before we return, so the size is right back in native code.
+ bShouldWait = bFullscreenLayout;
+ }
+ }
+
+ if (bShouldWait && (getContext() != null)) {
+ // We'll wait for the surfaceChanged() method, which will notify us
+ // when called. That way, we know our current size is really the
+ // size we need, instead of grabbing a size that's still got
+ // the navigation and/or status bars before they're hidden.
+ //
+ // We'll wait for up to half a second, because some devices
+ // take a surprisingly long time for the surface resize, but
+ // then we'll just give up and return.
+ //
+ synchronized (getContext()) {
+ try {
+ getContext().wait(500);
+ } catch (InterruptedException ie) {
+ ie.printStackTrace();
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ // C functions we call
+ public static native String nativeGetVersion();
+ public static native void nativeSetupJNI();
+ public static native void nativeInitMainThread();
+ public static native void nativeCleanupMainThread();
+ public static native int nativeRunMain(String library, String function, Object arguments);
+ public static native void nativeLowMemory();
+ public static native void nativeSendQuit();
+ public static native void nativeQuit();
+ public static native void nativePause();
+ public static native void nativeResume();
+ public static native void nativeFocusChanged(boolean hasFocus);
+ public static native void onNativeDropFile(String filename);
+ public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float density, float rate);
+ public static native void onNativeResize();
+ public static native void onNativeKeyDown(int keycode);
+ public static native void onNativeKeyUp(int keycode);
+ public static native boolean onNativeSoftReturnKey();
+ public static native void onNativeKeyboardFocusLost();
+ public static native void onNativeMouse(int button, int action, float x, float y, boolean relative);
+ public static native void onNativeTouch(int touchDevId, int pointerFingerId,
+ int action, float x,
+ float y, float p);
+ public static native void onNativePen(int penId, int device_type, int button, int action, float x, float y, float p);
+ public static native void onNativeClipboardChanged();
+ public static native void onNativeSurfaceCreated();
+ public static native void onNativeSurfaceChanged();
+ public static native void onNativeSurfaceDestroyed();
+ public static native void onNativeScreenKeyboardShown();
+ public static native void onNativeScreenKeyboardHidden();
+ public static native String nativeGetHint(String name);
+ public static native boolean nativeGetHintBoolean(String name, boolean default_value);
+ public static native void nativeSetenv(String name, String value);
+ public static native void nativeSetNaturalOrientation(int orientation);
+ public static native void onNativeRotationChanged(int rotation);
+ public static native void onNativeInsetsChanged(int left, int right, int top, int bottom);
+ public static native void nativeAddTouch(int touchId, String name);
+ public static native void nativePermissionResult(int requestCode, boolean result);
+ public static native void onNativeLocaleChanged();
+ public static native void onNativeDarkModeChanged(boolean enabled);
+ public static native boolean nativeAllowRecreateActivity();
+ public static native int nativeCheckSDLThreadCounter();
+ public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter);
+ public static native void onNativePinchStart(float span_x, float span_y, float focus_x, float focus_y);
+ public static native void onNativePinchUpdate(float scale, float span_x, float span_y, float focus_x, float focus_y);
+ public static native void onNativePinchEnd(float span_x, float span_y, float focus_x, float focus_y);
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setActivityTitle(String title) {
+ // Called from SDLMain() thread and can't directly affect the view
+ return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void setWindowStyle(boolean fullscreen) {
+ // Called from SDLMain() thread and can't directly affect the view
+ mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ * This is a static method for JNI convenience, it calls a non-static method
+ * so that is can be overridden
+ */
+ public static void setOrientation(int w, int h, boolean resizable, String hint)
+ {
+ if (mSingleton != null) {
+ mSingleton.setOrientationBis(w, h, resizable, hint);
+ }
+ }
+
+ /**
+ * This can be overridden
+ */
+ public void setOrientationBis(int w, int h, boolean resizable, String hint)
+ {
+ int orientation_landscape = -1;
+ int orientation_portrait = -1;
+
+ if (w <= 1 || h <= 1) {
+ // Invalid width/height, ignore this request
+ return;
+ }
+
+ /* If set, hint "explicitly controls which UI orientations are allowed". */
+ if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
+ } else if (hint.contains("LandscapeLeft")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ } else if (hint.contains("LandscapeRight")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ }
+
+ /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */
+ boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait");
+
+ if (contains_Portrait && hint.contains("PortraitUpsideDown")) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
+ } else if (contains_Portrait) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ } else if (hint.contains("PortraitUpsideDown")) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ }
+
+ boolean is_landscape_allowed = (orientation_landscape != -1);
+ boolean is_portrait_allowed = (orientation_portrait != -1);
+ int req; /* Requested orientation */
+
+ /* No valid hint, nothing is explicitly allowed */
+ if (!is_portrait_allowed && !is_landscape_allowed) {
+ if (resizable) {
+ /* All orientations are allowed, respecting user orientation lock setting */
+ req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
+ } else {
+ /* Fixed window and nothing specified. Get orientation from w/h of created window */
+ req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT);
+ }
+ } else {
+ /* At least one orientation is allowed */
+ if (resizable) {
+ if (is_portrait_allowed && is_landscape_allowed) {
+ /* hint allows both landscape and portrait, promote to full user */
+ req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
+ } else {
+ /* Use the only one allowed "orientation" */
+ req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+ }
+ } else {
+ /* Fixed window and both orientations are allowed. Choose one. */
+ if (is_portrait_allowed && is_landscape_allowed) {
+ req = (w > h ? orientation_landscape : orientation_portrait);
+ } else {
+ /* Use the only one allowed "orientation" */
+ req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+ }
+ }
+ }
+
+ Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint);
+ mSingleton.setRequestedOrientation(req);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void minimizeWindow() {
+
+ if (mSingleton == null) {
+ return;
+ }
+
+ Intent startMain = new Intent(Intent.ACTION_MAIN);
+ startMain.addCategory(Intent.CATEGORY_HOME);
+ startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mSingleton.startActivity(startMain);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean shouldMinimizeOnFocusLoss() {
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean supportsRelativeMouse()
+ {
+ // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under
+ // Android 7 APIs, and simply returns no data under Android 8 APIs.
+ //
+ // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and
+ // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result,
+ // we should stick to relative mode.
+ //
+ if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) {
+ return false;
+ }
+
+ return SDLActivity.getMotionListener().supportsRelativeMouse();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setRelativeMouseEnabled(boolean enabled)
+ {
+ if (enabled && !supportsRelativeMouse()) {
+ return false;
+ }
+
+ return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean sendMessage(int command, int param) {
+ if (mSingleton == null) {
+ return false;
+ }
+ return mSingleton.sendCommand(command, param);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static Activity getContext() {
+ return SDL.getContext();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isAndroidTV() {
+ UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE);
+ if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("Amlogic") &&
+ (Build.MODEL.startsWith("TV") ||
+ Build.MODEL.equals("X96-W") ||
+ Build.MODEL.equals("A95X-R1"))) {
+ return true;
+ }
+ return false;
+ }
+
+ public static boolean isVRHeadset() {
+ if (Build.MANUFACTURER.equals("Oculus") && Build.MODEL.startsWith("Quest")) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("Pico")) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ static String getDeviceFormFactor()
+ {
+ // TODO: WearOS
+ if (isAndroidTV()) {
+ return "tv";
+ } else if (isVRHeadset()) {
+ return "headset";
+ } else if (isTablet()) {
+ return "tablet";
+ //} else if (isAndroidAutomotive()) {
+ // return "car";
+ } else {
+ return "phone";
+ }
+ }
+
+ public static double getDiagonal()
+ {
+ DisplayMetrics metrics = new DisplayMetrics();
+ Activity activity = getContext();
+ if (activity == null) {
+ return 0.0;
+ }
+ activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);
+
+ double dWidthInches = metrics.widthPixels / (double)metrics.xdpi;
+ double dHeightInches = metrics.heightPixels / (double)metrics.ydpi;
+
+ return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches));
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isTablet() {
+ // If our diagonal size is seven inches or greater, we consider ourselves a tablet.
+ return (getDiagonal() >= 7.0);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isChromebook() {
+ // https://stackoverflow.com/questions/39784415/how-to-detect-programmatically-if-android-app-is-running-in-chrome-book-or-in
+ if (getContext() != null) {
+ if (getContext().getPackageManager().hasSystemFeature("org.chromium.arc")
+ || getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) {
+ return true;
+ }
+ }
+
+ // Running on AVD emulator
+ boolean isChromebookEmulator = (Build.MODEL != null && Build.MODEL.startsWith("sdk_gpc_"));
+ return isChromebookEmulator;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isDeXMode() {
+ if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) {
+ return false;
+ }
+ try {
+ final Configuration config = getContext().getResources().getConfiguration();
+ final Class> configClass = config.getClass();
+ return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
+ == configClass.getField("semDesktopModeEnabled").getInt(config);
+ } catch(Exception ignored) {
+ return false;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean getManifestEnvironmentVariables() {
+ try {
+ if (getContext() == null) {
+ return false;
+ }
+
+ ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA);
+ Bundle bundle = applicationInfo.metaData;
+ if (bundle == null) {
+ return false;
+ }
+ String prefix = "SDL_ENV.";
+ final int trimLength = prefix.length();
+ for (String key : bundle.keySet()) {
+ if (key.startsWith(prefix)) {
+ String name = key.substring(trimLength);
+ String value = bundle.get(key).toString();
+ nativeSetenv(name, value);
+ }
+ }
+ /* environment variables set! */
+ return true;
+ } catch (Exception e) {
+ Log.v(TAG, "exception " + e.toString());
+ }
+ return false;
+ }
+
+ // This method is called by SDLControllerManager's API 26 Generic Motion Handler.
+ public static View getContentView() {
+ return mLayout;
+ }
+
+ static class ShowTextInputTask implements Runnable {
+ /*
+ * This is used to regulate the pan&scan method to have some offset from
+ * the bottom edge of the input region and the top edge of an input
+ * method (soft keyboard)
+ */
+ static final int HEIGHT_PADDING = 15;
+
+ public int input_type;
+ public int x, y, w, h;
+
+ public ShowTextInputTask(int input_type, int x, int y, int w, int h) {
+ this.input_type = input_type;
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+
+ /* Minimum size of 1 pixel, so it takes focus. */
+ if (this.w <= 0) {
+ this.w = 1;
+ }
+ if (this.h + HEIGHT_PADDING <= 0) {
+ this.h = 1 - HEIGHT_PADDING;
+ }
+ }
+
+ @Override
+ public void run() {
+ RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING);
+ params.leftMargin = x;
+ params.topMargin = y;
+
+ if (mTextEdit == null) {
+ mTextEdit = new SDLDummyEdit(getContext());
+
+ mLayout.addView(mTextEdit, params);
+ } else {
+ mTextEdit.setLayoutParams(params);
+ }
+ mTextEdit.setInputType(input_type);
+
+ mTextEdit.setVisibility(View.VISIBLE);
+ mTextEdit.requestFocus();
+
+ InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(mTextEdit, 0);
+
+ if (imm.isAcceptingText()) {
+ onNativeScreenKeyboardShown();
+ }
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean showTextInput(int input_type, int x, int y, int w, int h) {
+ // Transfer the task to the main thread as a Runnable
+ return mSingleton.commandHandler.post(new ShowTextInputTask(input_type, x, y, w, h));
+ }
+
+ public static boolean isTextInputEvent(KeyEvent event) {
+
+ // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT
+ if (event.isCtrlPressed()) {
+ return false;
+ }
+
+ return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE;
+ }
+
+ public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) {
+ int deviceId = event.getDeviceId();
+ int source = event.getSource();
+ InputDevice device = InputDevice.getDevice(deviceId);
+
+ if (source == InputDevice.SOURCE_UNKNOWN) {
+ if (device != null) {
+ source = device.getSources();
+ }
+ }
+
+// if (event.getAction() == KeyEvent.ACTION_DOWN) {
+// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+// } else if (event.getAction() == KeyEvent.ACTION_UP) {
+// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+// }
+
+ // Dispatch the different events depending on where they come from
+ // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD
+ // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD
+ //
+ // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and
+ // SOURCE_JOYSTICK, while its key events arrive from the keyboard source
+ // So, retrieve the device itself and check all of its sources
+ if (SDLControllerManager.isDeviceSDLJoystick(device)) {
+ // Note that we process events with specific key codes here
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ if (SDLControllerManager.onNativePadDown(deviceId, keyCode, event.getScanCode())) {
+ return true;
+ }
+ } else if (event.getAction() == KeyEvent.ACTION_UP) {
+ if (SDLControllerManager.onNativePadUp(deviceId, keyCode, event.getScanCode())) {
+ return true;
+ }
+ }
+ }
+
+ if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) {
+ if (SDLActivity.isVRHeadset()) {
+ // The Oculus Quest controller back button comes in as source mouse, so accept that
+ } else {
+ // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses
+ // they are ignored here because sending them as mouse input to SDL is messy
+ if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) {
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_DOWN:
+ case KeyEvent.ACTION_UP:
+ // mark the event as handled or it will be handled by system
+ // handling KEYCODE_BACK by system will call onBackPressed()
+ return true;
+ }
+ }
+ }
+ }
+
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ onNativeKeyDown(keyCode);
+
+ if (isTextInputEvent(event)) {
+ if (ic != null) {
+ ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1);
+ } else {
+ SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1);
+ }
+ }
+ return true;
+ } else if (event.getAction() == KeyEvent.ACTION_UP) {
+ onNativeKeyUp(keyCode);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static Surface getNativeSurface() {
+ if (SDLActivity.mSurface == null) {
+ return null;
+ }
+ return SDLActivity.mSurface.getNativeSurface();
+ }
+
+ // Input
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void initTouch() {
+ int[] ids = InputDevice.getDeviceIds();
+
+ for (int id : ids) {
+ InputDevice device = InputDevice.getDevice(id);
+ /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */
+ if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN
+ || device.isVirtual())) {
+
+ nativeAddTouch(device.getId(), device.getName());
+ }
+ }
+ }
+
+ // Messagebox
+
+ /** Result of current messagebox. Also used for blocking the calling thread. */
+ protected final int[] messageboxSelection = new int[1];
+
+ /**
+ * This method is called by SDL using JNI.
+ * Shows the messagebox from UI thread and block calling thread.
+ * buttonFlags, buttonIds and buttonTexts must have same length.
+ * @param buttonFlags array containing flags for every button.
+ * @param buttonIds array containing id for every button.
+ * @param buttonTexts array containing text for every button.
+ * @param colors null for default or array of length 5 containing colors.
+ * @return button id or -1.
+ */
+ public int messageboxShowMessageBox(
+ final int flags,
+ final String title,
+ final String message,
+ final int[] buttonFlags,
+ final int[] buttonIds,
+ final String[] buttonTexts,
+ final int[] colors) {
+
+ messageboxSelection[0] = -1;
+
+ // sanity checks
+
+ if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) {
+ return -1; // implementation broken
+ }
+
+ // collect arguments for Dialog
+
+ final Bundle args = new Bundle();
+ args.putInt("flags", flags);
+ args.putString("title", title);
+ args.putString("message", message);
+ args.putIntArray("buttonFlags", buttonFlags);
+ args.putIntArray("buttonIds", buttonIds);
+ args.putStringArray("buttonTexts", buttonTexts);
+ args.putIntArray("colors", colors);
+
+ // trigger Dialog creation on UI thread
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ messageboxCreateAndShow(args);
+ }
+ });
+
+ // block the calling thread
+
+ synchronized (messageboxSelection) {
+ try {
+ messageboxSelection.wait();
+ } catch (InterruptedException ex) {
+ ex.printStackTrace();
+ return -1;
+ }
+ }
+
+ // return selected value
+
+ return messageboxSelection[0];
+ }
+
+ protected void messageboxCreateAndShow(Bundle args) {
+
+ // TODO set values from "flags" to messagebox dialog
+
+ // get colors
+
+ int[] colors = args.getIntArray("colors");
+ int backgroundColor;
+ int textColor;
+ int buttonBorderColor;
+ int buttonBackgroundColor;
+ int buttonSelectedColor;
+ if (colors != null) {
+ int i = -1;
+ backgroundColor = colors[++i];
+ textColor = colors[++i];
+ buttonBorderColor = colors[++i];
+ buttonBackgroundColor = colors[++i];
+ buttonSelectedColor = colors[++i];
+ } else {
+ backgroundColor = Color.TRANSPARENT;
+ textColor = Color.TRANSPARENT;
+ buttonBorderColor = Color.TRANSPARENT;
+ buttonBackgroundColor = Color.TRANSPARENT;
+ buttonSelectedColor = Color.TRANSPARENT;
+ }
+
+ // create dialog with title and a listener to wake up calling thread
+
+ final AlertDialog dialog = new AlertDialog.Builder(this).create();
+ dialog.setTitle(args.getString("title"));
+ dialog.setCancelable(false);
+ dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface unused) {
+ synchronized (messageboxSelection) {
+ messageboxSelection.notify();
+ }
+ }
+ });
+
+ // create text
+
+ TextView message = new TextView(this);
+ message.setGravity(Gravity.CENTER);
+ message.setText(args.getString("message"));
+ if (textColor != Color.TRANSPARENT) {
+ message.setTextColor(textColor);
+ }
+
+ // create buttons
+
+ int[] buttonFlags = args.getIntArray("buttonFlags");
+ int[] buttonIds = args.getIntArray("buttonIds");
+ String[] buttonTexts = args.getStringArray("buttonTexts");
+
+ final SparseArray