diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b2a8ff0 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,123 @@ +cmake_minimum_required(VERSION 3.16) +project(Rousku LANGUAGES CXX) + +if(${CMAKE_SYSTEM_NAME} STREQUAL "Android") + set(TARGET_PLATFORM_ANDROID TRUE) +else() + set(TARGET_PLATFORM_LINUX TRUE) +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Include compile flags +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -g") + +if(TARGET_PLATFORM_ANDROID) + # These will find .aar packages in android build environment. + # SDL3 needs to be build by yourself as we are not using Android Activity, so not using .aar. + #find_package(SDL3 CONFIG REQUIRED) + find_package(SDL3_image CONFIG REQUIRED) + find_package(SDL3_ttf CONFIG REQUIRED) + + # These are needed for SDL3 when not using .aar. + include_directories(/SDL/include) + add_library(SDL3 STATIC IMPORTED) + set_target_properties(SDL3 PROPERTIES IMPORTED_LOCATION /SDL/build-android/libSDL3.so) + + #include_directories(${SDL3_INCLUDE_DIRS}) + #link_libraries(${SDL3_LIBRARIES}) +else() + # Get SDL3 and friends using pkg-config + find_package(PkgConfig REQUIRED) + pkg_check_modules(SDL3 REQUIRED sdl3 sdl3-ttf sdl3-image) +endif() + +# Define sources +set(NEW_DIALOG_OBJECTS + Dialog + Item + Button + Checkbox + DropDownList + Scrollbar + Listbox + Frame + DlgDropDownList + DlgMainMenu +) + +list(TRANSFORM NEW_DIALOG_OBJECTS PREPEND "NewDialogs/") + +set(SOURCES + main.cpp + Animal.cpp + Sound.cpp +) + +foreach(src ${NEW_DIALOG_OBJECTS}) + list(APPEND SOURCES "${src}.cpp") +endforeach() + +# Generate embed_data.h +add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/embed_data.h + COMMAND ${CMAKE_COMMAND} -E echo "Generating embed_data.h..." + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR} + COMMAND /bin/sh -c "> ${CMAKE_CURRENT_BINARY_DIR}/embed_data.h && \ +find ${CMAKE_SOURCE_DIR}/skin -name '*.png' -o -name '*.wav' -o -name '*.tsk' | \ +while read file; do \ +xxd -i \"\$file\" >> ${CMAKE_CURRENT_BINARY_DIR}/embed_data.h; \ +done" + DEPENDS ${CMAKE_SOURCE_DIR}/skin + VERBATIM +) + +# Generate menu_png.h +add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/menu_png.h + COMMAND xxd -i ${CMAKE_SOURCE_DIR}/menu.png > ${CMAKE_CURRENT_BINARY_DIR}/menu_png.h + DEPENDS ${CMAKE_SOURCE_DIR}/menu.png + VERBATIM +) + +# Add custom headers to build +add_custom_target(generate_headers + DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/embed_data.h ${CMAKE_CURRENT_BINARY_DIR}/menu_png.h +) + +if(TARGET_PLATFORM_ANDROID) + # Add executable + add_library(main SHARED ${SOURCES}) + + # Include generated headers + add_dependencies(main generate_headers) + target_include_directories(main PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + + # These are got from .aar package. SDL3 is build by ourselves so not including it. + #target_link_libraries(main PRIVATE SDL3::SDL3) + target_link_libraries(main PRIVATE SDL3 SDL3_image::SDL3_image SDL3_ttf::SDL3_ttf android log OpenSLES EGL GLESv2) + + # Install instructions + install(TARGETS main DESTINATION bin) +else() + # Add executable + add_executable(Rousku ${SOURCES}) + + # Include generated headers + add_dependencies(Rousku generate_headers) + target_include_directories(Rousku PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + + # Link libraries + target_link_libraries(Rousku ${SDL3_LIBRARIES}) + + # Install instructions + install(TARGETS Rousku DESTINATION bin) +endif() + +install(FILES skin/Tavallinen/paikka/1.png DESTINATION share/icons RENAME rousku.png) + +# Custom clean +set_directory_properties(PROPERTIES ADDITIONAL_MAKE_CLEAN_FILES + "${CMAKE_CURRENT_BINARY_DIR}/embed_data.h;${CMAKE_CURRENT_BINARY_DIR}/menu_png.h" +) diff --git a/INSTALL.android b/INSTALL.android new file mode 100644 index 0000000..f5ac783 --- /dev/null +++ b/INSTALL.android @@ -0,0 +1,84 @@ +Install SDK: +Get started @ https://github.com/thyrlian/AndroidSDK +docker pull thyrlian/android-sdk +docker run -it --rm -v $(pwd)/sdk:/sdk thyrlian/android-sdk bash -c 'cp -a $ANDROID_HOME/. /sdk' +sdk/cmdline-tools/tools/bin/sdkmanager --update + +Use build environtment to build SDL: +docker run -it -v $(pwd)/sdk:/opt/android-sdk -v ~/git/SDL:/SDL -v ~/git/SDLRousku thyrlian/android-sdk /bin/bash + +Need to build SDL3 without video support, so that we can use it on Android without Android Activity: + +Need to make some changes to SDL: +SDL/src/SDL_assert.c and SDL/src/SDL_log.c: +stderr and stdin removed + +diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c +index 46cef905e..2bbe7b7dc 100644 +--- a/src/core/android/SDL_android.c ++++ b/src/core/android/SDL_android.c +@@ -48,7 +48,7 @@ + #define SDL_JAVA_PREFIX org_libsdl_app + #define CONCAT1(prefix, class, function) CONCAT2(prefix, class, function) + #define CONCAT2(prefix, class, function) Java_##prefix##_##class##_##function +-#define SDL_JAVA_INTERFACE(function) CONCAT1(SDL_JAVA_PREFIX, SDLActivity, function) ++#define SDL_JAVA_INTERFACE(function) CONCAT1(SDL_JAVA_PREFIX, SDLService, function) + #define SDL_JAVA_AUDIO_INTERFACE(function) CONCAT1(SDL_JAVA_PREFIX, SDLAudioManager, function) + #define SDL_JAVA_CONTROLLER_INTERFACE(function) CONCAT1(SDL_JAVA_PREFIX, SDLControllerManager, function) + #define SDL_JAVA_INTERFACE_INPUT_CONNECTION(function) CONCAT1(SDL_JAVA_PREFIX, SDLInputConnection, function) +@@ -557,7 +557,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) + return JNI_VERSION_1_4; + } + +- register_methods(env, "org/libsdl/app/SDLActivity", SDLActivity_tab, SDL_arraysize(SDLActivity_tab)); ++ register_methods(env, "org/libsdl/app/SDLService", SDLActivity_tab, SDL_arraysize(SDLActivity_tab)); + register_methods(env, "org/libsdl/app/SDLInputConnection", SDLInputConnection_tab, SDL_arraysize(SDLInputConnection_tab)); + register_methods(env, "org/libsdl/app/SDLAudioManager", SDLAudioManager_tab, SDL_arraysize(SDLAudioManager_tab)); + register_methods(env, "org/libsdl/app/SDLControllerManager", SDLControllerManager_tab, SDL_arraysize(SDLControllerManager_tab)); + + +mkdir build-novideo +cd build-novideo +cmake .. \ + -DSDL_TEST=OFF \ + -DSDL_SHARED=ON \ + -DSDL_STATIC=OFF \ + -DSDL_VIDEO=ON \ + -DSDL_RENDER=ON \ + -DSDL_JOYSTICK=ON \ + -DSDL_AUDIO=ON \ + -DSDL_EVENTS=ON \ + -DSDL_THREADS=ON \ + -DSDL_TIMER=ON \ + -DSDL_HIDAPI=OFF \ + -DCMAKE_SYSTEM_NAME=Android \ + -DCMAKE_ANDROID_NDK=/opt/android-sdk/ndk/25.1.8937393/ \ + -DCMAKE_ANDROID_ARCH_ABI=arm64-v8a \ + -DCMAKE_ANDROID_STL_TYPE=c++_static \ + -DANDROID_PLATFORM=23 \ + -DANDROID_ABI=arm64-v8a \ + -DCMAKE_TOOLCHAIN_FILE=/opt/android-sdk/ndk/25.1.8937393/build/cmake/android.toolchain.cmake + +cmake --build . --config Release + + +Use build environment to build SDLRousku: +FreeSans.ttf needs to be placed to android-project/app/src/main/assets +SDL3_image-3.2.4.aar needs to be placed to android-project/app/libs/ +SDL3_ttf-3.2.2.aar needs to be placed to android-project/app/libs/ + +copy gradle from SDL to /SDLRousku/android-project/: +SDL/android-project/gradle/wrapper/gradle-wrapper.jar +SDL/android-project/gradle/wrapper/gradle-wrapper.properties +SDL/android-project/gradlew +SDL/android-project/gradlew.bat + +cd /SDLRousku/android-project +./gradlew installDebug + +App is found @ ./app/build/outputs/apk/debug/app-debug.apk + +and release: +./gradlew assembleRelease + +App is found @ ./app/build/outputs/apk/release/app-release-unsigned.apk diff --git a/NewDialogs/Dialog.cpp b/NewDialogs/Dialog.cpp index 0ac4e68..62d9343 100644 --- a/NewDialogs/Dialog.cpp +++ b/NewDialogs/Dialog.cpp @@ -68,7 +68,7 @@ bool Dialog::onMousePress(int x, int y) { for (unsigned int z = 0; z < items.size(); z++) { items[z]->onMousePress(x, y); } - return false; + return true; } bool Dialog::onMouseRelease(int x, int y) { @@ -93,7 +93,7 @@ bool Dialog::onFingerDown(int x, int y, int finger_id) { for (unsigned int z = 0; z < items.size(); z++) { items[z]->onFingerDown(x, y, finger_id); } - return false; + return true; } bool Dialog::onFingerUp(int x, int y, int finger_id) { diff --git a/SDLHelpers.h b/SDLHelpers.h index 65880cd..c6a171f 100644 --- a/SDLHelpers.h +++ b/SDLHelpers.h @@ -12,7 +12,11 @@ #include #endif -static const char *font_paths[] = {"C:/windows/fonts/", "/usr/share/fonts/TTF/", "./"}; +#ifdef SDL_PLATFORM_ANDROID +#include +#endif + +static const char *font_paths[] = {"C:/windows/fonts/", "/usr/share/fonts/TTF/", "./", "./assets/"}; static const short font_path_count = 3; static SDL_Rect getRect(Sint16 x, Sint16 y, Uint16 w, Uint16 h) { @@ -34,6 +38,20 @@ static SDL_Color getColor(Uint8 r, Uint8 g, Uint8 b) { static TTF_Font *openFont(std::vector fonts, int size) { TTF_Font *font = NULL; +#ifdef SDL_PLATFORM_ANDROID + SDL_IOStream* rw = SDL_IOFromFile("FreeSans.ttf", "rb"); + if (!rw) { + SDL_Log("SDL_IOFromFile failed: %s", SDL_GetError()); + return nullptr; + } + + font = TTF_OpenFontIO(rw, 1, size); // 1 = auto-free the IOStream + if (!font) { + SDL_Log("TTF_OpenFontRW failed: %s", SDL_GetError()); + return nullptr; + } + return font; +#endif for (int x = 0; x < fonts.size(); x++) { #ifdef SDL_PLATFORM_WINDOWS TCHAR windir[MAX_PATH]; diff --git a/android-project/app/build.gradle b/android-project/app/build.gradle new file mode 100644 index 0000000..b3607e3 --- /dev/null +++ b/android-project/app/build.gradle @@ -0,0 +1,70 @@ +plugins { + id 'com.android.application' +} + +//def buildWithCMake = project.hasProperty('BUILD_WITH_CMAKE'); +def buildWithCMake = true; + +android { + namespace "fi.jetro.rousku" + compileSdkVersion 34 + defaultConfig { + minSdkVersion 23 + targetSdkVersion 34 + versionCode 1 + versionName "1.0" + externalNativeBuild { + ndkBuild { + arguments "APP_PLATFORM=android-19" + // abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + abiFilters 'arm64-v8a' + } + cmake { + arguments "-DANDROID_PLATFORM=android-19", "-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 'jni/CMakeLists.txt' + path '../../CMakeLists.txt' + } + } else { + ndkBuild { + path 'jni/Android.mk' + } + } + } + + } + lint { + abortOnError false + } + buildFeatures { + prefab true + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + //implementation files('libs/SDL3-3.2.20.aar') + implementation files('libs/SDL3_ttf-3.2.2.aar') + implementation files('libs/SDL3_image-3.2.4.aar') +} diff --git a/android-project/app/src/main/AndroidManifest.xml b/android-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9e5a1aa --- /dev/null +++ b/android-project/app/src/main/AndroidManifest.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-project/app/src/main/java/fi/jetro/rousku/RouskuActivity.java b/android-project/app/src/main/java/fi/jetro/rousku/RouskuActivity.java new file mode 100644 index 0000000..4503f9a --- /dev/null +++ b/android-project/app/src/main/java/fi/jetro/rousku/RouskuActivity.java @@ -0,0 +1,42 @@ + +package fi.jetro.rousku; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.os.Bundle; +import android.provider.Settings; + +public class RouskuActivity extends Activity { + + @Override + protected void onResume() { + super.onResume(); + + if (Settings.canDrawOverlays(this)) { + Intent intent = new Intent(RouskuActivity.this, RouskuService.class); + startForegroundService(intent); + finish(); + } else { + showOverlayPermissionDialog(); + } + } + + private void showOverlayPermissionDialog() { + new AlertDialog.Builder(this) + .setTitle(getString(R.string.permission_required)) + .setMessage(getString(R.string.overlay_permission_description)) + .setCancelable(false) + .setPositiveButton(getString(R.string.permission_accept), (dialog, which) -> { + Intent intent = new Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + android.net.Uri.parse("package:" + getPackageName()) + ); + startActivity(intent); + }) + .setNegativeButton(getString(R.string.permission_deny), (dialog, which) -> { + finishAffinity(); + }) + .show(); + } +} diff --git a/android-project/app/src/main/java/fi/jetro/rousku/RouskuService.java b/android-project/app/src/main/java/fi/jetro/rousku/RouskuService.java new file mode 100644 index 0000000..b7a162b --- /dev/null +++ b/android-project/app/src/main/java/fi/jetro/rousku/RouskuService.java @@ -0,0 +1,44 @@ + +package fi.jetro.rousku; + +import org.libsdl.app.SDLService; + +import android.app.Service; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.pm.ServiceInfo; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; + +public class RouskuService extends SDLService { + private static final String CHANNEL_ID = "channel1"; + + public void onCreate() { + createNotificationChannel(); + startForeground(100, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + super.onCreate(); + } + + private void createNotificationChannel() { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ); + getSystemService(NotificationManager.class).createNotificationChannel(channel); + } + + private Notification createNotification() { + return new Notification.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setSmallIcon(android.R.drawable.ic_menu_edit) + .setPriority(Notification.PRIORITY_LOW) + .setOngoing(true) + .build(); + } + + @Override public IBinder onBind(Intent intent) { return null; } + +} 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..d2dc0d2 --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,645 @@ +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.LinkedList; +import java.util.UUID; + +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 mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + 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 public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static public 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 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + 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; + } + + 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); + } + } + + public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + public String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + public 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); + } + + public 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(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // 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(); + } + + // Run in main thread + mHandler.post(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 + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + public void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + public void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + 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. + } + + 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 { + probeService(this); + } + } + } + + 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(); + } + + 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); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + 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(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + 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(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + 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() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @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 { + //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + } + + @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..37d80ca --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -0,0 +1,689 @@ +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; + + public static HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + public static 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 = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.commit(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + public Context getContext() { + return mContext; + } + + public 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.commit(); + 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 >= Build.VERSION_CODES.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 + 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 + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + 0x3537, // GameSir + }; + + 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); + } + } + } + } + + 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) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) { + 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 >= Build.VERSION_CODES.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. + public 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); + } + + public 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; + } + + public 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); + } + } + + public 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; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + 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 + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + public 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; + } + if (Build.VERSION.SDK_INT >= 33 /* Android 14.0 (U) */) { + Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION); + intent.setPackage(mContext.getPackageName()); + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags)); + } else { + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), 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; + } + + public 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; + } + + public 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; + } + + public 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); + 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..5b5d201 --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,308 @@ +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; + +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; + + 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; + } + + public String getIdentifier() { + return String.format("%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; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException 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 = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getManufacturerName(); + } + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getProductName(); + } + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + public 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; + } + + // 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 + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + 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 (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 { + 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 (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) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + 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..021d361 --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,90 @@ +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLService.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLService.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + loadLibrary(libraryName, mContext); + } + + public 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 Context mContext; +} diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLAudioManager.java b/android-project/app/src/main/java/org/libsdl/app/SDLAudioManager.java new file mode 100644 index 0000000..1fe064e --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/SDLAudioManager.java @@ -0,0 +1,126 @@ +package org.libsdl.app; + +import android.content.Context; +import android.media.AudioDeviceCallback; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.Build; +import android.util.Log; + +import java.util.Arrays; +import java.util.ArrayList; + +public class SDLAudioManager { + protected static final String TAG = "SDLAudio"; + + protected static Context mContext; + + private static AudioDeviceCallback mAudioDeviceCallback; + + public static void initialize() { + mAudioDeviceCallback = null; + + if(Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) + { + mAudioDeviceCallback = new AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { + for (AudioDeviceInfo deviceInfo : addedDevices) { + nativeAddAudioDevice(deviceInfo.isSink(), deviceInfo.getProductName().toString(), deviceInfo.getId()); + } + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { + for (AudioDeviceInfo deviceInfo : removedDevices) { + nativeRemoveAudioDevice(deviceInfo.isSink(), deviceInfo.getId()); + } + } + }; + } + } + + public static void setContext(Context context) { + mContext = context; + } + + public static void release(Context context) { + // no-op atm + } + + // Audio + + private static AudioDeviceInfo getInputAudioDeviceInfo(int deviceId) { + if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + for (AudioDeviceInfo deviceInfo : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (deviceInfo.getId() == deviceId) { + return deviceInfo; + } + } + } + return null; + } + + private static AudioDeviceInfo getPlaybackAudioDeviceInfo(int deviceId) { + if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + for (AudioDeviceInfo deviceInfo : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (deviceInfo.getId() == deviceId) { + return deviceInfo; + } + } + } + return null; + } + + public static void registerAudioDeviceCallback() { + if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + // get an initial list now, before hotplug callbacks fire. + for (AudioDeviceInfo dev : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (dev.getType() == AudioDeviceInfo.TYPE_TELEPHONY) { + continue; // Device cannot be opened + } + nativeAddAudioDevice(dev.isSink(), dev.getProductName().toString(), dev.getId()); + } + for (AudioDeviceInfo dev : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + nativeAddAudioDevice(dev.isSink(), dev.getProductName().toString(), dev.getId()); + } + audioManager.registerAudioDeviceCallback(mAudioDeviceCallback, null); + } + } + + public static void unregisterAudioDeviceCallback() { + if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + audioManager.unregisterAudioDeviceCallback(mAudioDeviceCallback); + } + } + + /** This method is called by SDL using JNI. */ + public static void audioSetThreadPriority(boolean recording, int device_id) { + try { + + /* Set thread name */ + if (recording) { + Thread.currentThread().setName("SDLAudioC" + device_id); + } else { + Thread.currentThread().setName("SDLAudioP" + device_id); + } + + /* Set thread priority */ + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO); + + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + } + + public static native int nativeSetupJNI(); + + public static native void nativeRemoveAudioDevice(boolean recording, int deviceId); + + public static native void nativeAddAudioDevice(boolean recording, String name, int deviceId); + +} diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java b/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java new file mode 100644 index 0000000..29ea338 --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java @@ -0,0 +1,899 @@ +package org.libsdl.app; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; +import android.util.Log; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + + +public class SDLControllerManager +{ + + public static native int nativeSetupJNI(); + + public static native void nativeAddJoystick(int device_id, String name, String desc, + int vendor_id, int product_id, + int button_mask, + int naxes, int axis_mask, int nhats, boolean can_rumble); + public static native void nativeRemoveJoystick(int device_id); + public static native void nativeAddHaptic(int device_id, String name); + public static native void nativeRemoveHaptic(int device_id); + public static native boolean onNativePadDown(int device_id, int keycode); + public static native boolean onNativePadUp(int device_id, int keycode); + public static native void onNativeJoy(int device_id, int axis, + float value); + public static native void onNativeHat(int device_id, int hat_id, + int x, int y); + + protected static SDLJoystickHandler mJoystickHandler; + protected static SDLHapticHandler mHapticHandler; + + private static final String TAG = "SDLControllerManager"; + + public static void initialize() { + if (mJoystickHandler == null) { + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + mJoystickHandler = new SDLJoystickHandler_API19(); + } else { + mJoystickHandler = new SDLJoystickHandler_API16(); + } + } + + if (mHapticHandler == null) { + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + mHapticHandler = new SDLHapticHandler_API31(); + } else if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mHapticHandler = new SDLHapticHandler_API26(); + } else { + mHapticHandler = new SDLHapticHandler(); + } + } + } + + // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance + public static boolean handleJoystickMotionEvent(MotionEvent event) { + return mJoystickHandler.handleMotionEvent(event); + } + + /** + * This method is called by SDL using JNI. + */ + public static void pollInputDevices() { + mJoystickHandler.pollInputDevices(); + } + + /** + * This method is called by SDL using JNI. + */ + public static void pollHapticDevices() { + mHapticHandler.pollHapticDevices(); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticRun(int device_id, float intensity, int length) { + mHapticHandler.run(device_id, intensity, length); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticRumble(int device_id, float low_frequency_intensity, float high_frequency_intensity, int length) { + mHapticHandler.rumble(device_id, low_frequency_intensity, high_frequency_intensity, length); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticStop(int device_id) + { + mHapticHandler.stop(device_id); + } + + // Check if a given device is considered a possible SDL joystick + public static boolean isDeviceSDLJoystick(int deviceId) { + InputDevice device = InputDevice.getDevice(deviceId); + // We cannot use InputDevice.isVirtual before API 16, so let's accept + // only nonnegative device ids (VIRTUAL_KEYBOARD equals -1) + if ((device == null) || (deviceId < 0)) { + return false; + } + int sources = device.getSources(); + + /* This is called for every button press, so let's not spam the logs */ + /* + if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + Log.v(TAG, "Input device " + device.getName() + " has class joystick."); + } + if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) { + Log.v(TAG, "Input device " + device.getName() + " is a dpad."); + } + if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + Log.v(TAG, "Input device " + device.getName() + " is a gamepad."); + } + */ + + return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 || + ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) || + ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) + ); + } + +} + +class SDLJoystickHandler { + + /** + * Handles given MotionEvent. + * @param event the event to be handled. + * @return if given event was processed. + */ + public boolean handleMotionEvent(MotionEvent event) { + return false; + } + + /** + * Handles adding and removing of input devices. + */ + public void pollInputDevices() { + } +} + +/* Actual joystick functionality available for API >= 12 devices */ +class SDLJoystickHandler_API16 extends SDLJoystickHandler { + + static class SDLJoystick { + public int device_id; + public String name; + public String desc; + public ArrayList axes; + public ArrayList hats; + } + static class RangeComparator implements Comparator { + @Override + public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) { + // Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL + int arg0Axis = arg0.getAxis(); + int arg1Axis = arg1.getAxis(); + if (arg0Axis == MotionEvent.AXIS_GAS) { + arg0Axis = MotionEvent.AXIS_BRAKE; + } else if (arg0Axis == MotionEvent.AXIS_BRAKE) { + arg0Axis = MotionEvent.AXIS_GAS; + } + if (arg1Axis == MotionEvent.AXIS_GAS) { + arg1Axis = MotionEvent.AXIS_BRAKE; + } else if (arg1Axis == MotionEvent.AXIS_BRAKE) { + arg1Axis = MotionEvent.AXIS_GAS; + } + + // Make sure the AXIS_Z is sorted between AXIS_RY and AXIS_RZ. + // This is because the usual pairing are: + // - AXIS_X + AXIS_Y (left stick). + // - AXIS_RX, AXIS_RY (sometimes the right stick, sometimes triggers). + // - AXIS_Z, AXIS_RZ (sometimes the right stick, sometimes triggers). + // This sorts the axes in the above order, which tends to be correct + // for Xbox-ish game pads that have the right stick on RX/RY and the + // triggers on Z/RZ. + // + // Gamepads that don't have AXIS_Z/AXIS_RZ but use + // AXIS_LTRIGGER/AXIS_RTRIGGER are unaffected by this. + // + // References: + // - https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input + // - https://www.kernel.org/doc/html/latest/input/gamepad.html + if (arg0Axis == MotionEvent.AXIS_Z) { + arg0Axis = MotionEvent.AXIS_RZ - 1; + } else if (arg0Axis > MotionEvent.AXIS_Z && arg0Axis < MotionEvent.AXIS_RZ) { + --arg0Axis; + } + if (arg1Axis == MotionEvent.AXIS_Z) { + arg1Axis = MotionEvent.AXIS_RZ - 1; + } else if (arg1Axis > MotionEvent.AXIS_Z && arg1Axis < MotionEvent.AXIS_RZ) { + --arg1Axis; + } + + return arg0Axis - arg1Axis; + } + } + + private final ArrayList mJoysticks; + + public SDLJoystickHandler_API16() { + + mJoysticks = new ArrayList(); + } + + @Override + public void pollInputDevices() { + int[] deviceIds = InputDevice.getDeviceIds(); + + for (int device_id : deviceIds) { + if (SDLControllerManager.isDeviceSDLJoystick(device_id)) { + SDLJoystick joystick = getJoystick(device_id); + if (joystick == null) { + InputDevice joystickDevice = InputDevice.getDevice(device_id); + joystick = new SDLJoystick(); + joystick.device_id = device_id; + joystick.name = joystickDevice.getName(); + joystick.desc = getJoystickDescriptor(joystickDevice); + joystick.axes = new ArrayList(); + joystick.hats = new ArrayList(); + + List ranges = joystickDevice.getMotionRanges(); + Collections.sort(ranges, new RangeComparator()); + for (InputDevice.MotionRange range : ranges) { + if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) { + joystick.hats.add(range); + } else { + joystick.axes.add(range); + } + } + } + + boolean can_rumble = false; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + VibratorManager manager = joystickDevice.getVibratorManager(); + int[] vibrators = manager.getVibratorIds(); + if (vibrators.length > 0) { + can_rumble = true; + } + } + + mJoysticks.add(joystick); + SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc, + getVendorId(joystickDevice), getProductId(joystickDevice), + getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, can_rumble); + } + } + } + + /* Check removed devices */ + ArrayList removedDevices = null; + for (SDLJoystick joystick : mJoysticks) { + int device_id = joystick.device_id; + int i; + for (i = 0; i < deviceIds.length; i++) { + if (device_id == deviceIds[i]) break; + } + if (i == deviceIds.length) { + if (removedDevices == null) { + removedDevices = new ArrayList(); + } + removedDevices.add(device_id); + } + } + + if (removedDevices != null) { + for (int device_id : removedDevices) { + SDLControllerManager.nativeRemoveJoystick(device_id); + for (int i = 0; i < mJoysticks.size(); i++) { + if (mJoysticks.get(i).device_id == device_id) { + mJoysticks.remove(i); + break; + } + } + } + } + } + + protected SDLJoystick getJoystick(int device_id) { + for (SDLJoystick joystick : mJoysticks) { + if (joystick.device_id == device_id) { + return joystick; + } + } + return null; + } + + @Override + public boolean handleMotionEvent(MotionEvent event) { + int actionPointerIndex = event.getActionIndex(); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_MOVE) { + SDLJoystick joystick = getJoystick(event.getDeviceId()); + if (joystick != null) { + for (int i = 0; i < joystick.axes.size(); i++) { + InputDevice.MotionRange range = joystick.axes.get(i); + /* Normalize the value to -1...1 */ + float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f; + SDLControllerManager.onNativeJoy(joystick.device_id, i, value); + } + for (int i = 0; i < joystick.hats.size() / 2; i++) { + int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex)); + int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex)); + SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY); + } + } + } + return true; + } + + public String getJoystickDescriptor(InputDevice joystickDevice) { + String desc = joystickDevice.getDescriptor(); + + if (desc != null && !desc.isEmpty()) { + return desc; + } + + return joystickDevice.getName(); + } + public int getProductId(InputDevice joystickDevice) { + return 0; + } + public int getVendorId(InputDevice joystickDevice) { + return 0; + } + public int getAxisMask(List ranges) { + return -1; + } + public int getButtonMask(InputDevice joystickDevice) { + return -1; + } +} + +class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 { + + @Override + public int getProductId(InputDevice joystickDevice) { + return joystickDevice.getProductId(); + } + + @Override + public int getVendorId(InputDevice joystickDevice) { + return joystickDevice.getVendorId(); + } + + @Override + public int getAxisMask(List ranges) { + // For compatibility, keep computing the axis mask like before, + // only really distinguishing 2, 4 and 6 axes. + int axis_mask = 0; + if (ranges.size() >= 2) { + // ((1 << SDL_GAMEPAD_AXIS_LEFTX) | (1 << SDL_GAMEPAD_AXIS_LEFTY)) + axis_mask |= 0x0003; + } + if (ranges.size() >= 4) { + // ((1 << SDL_GAMEPAD_AXIS_RIGHTX) | (1 << SDL_GAMEPAD_AXIS_RIGHTY)) + axis_mask |= 0x000c; + } + if (ranges.size() >= 6) { + // ((1 << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1 << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)) + axis_mask |= 0x0030; + } + // Also add an indicator bit for whether the sorting order has changed. + // This serves to disable outdated gamecontrollerdb.txt mappings. + boolean have_z = false; + boolean have_past_z_before_rz = false; + for (InputDevice.MotionRange range : ranges) { + int axis = range.getAxis(); + if (axis == MotionEvent.AXIS_Z) { + have_z = true; + } else if (axis > MotionEvent.AXIS_Z && axis < MotionEvent.AXIS_RZ) { + have_past_z_before_rz = true; + } + } + if (have_z && have_past_z_before_rz) { + // If both these exist, the compare() function changed sorting order. + // Set a bit to indicate this fact. + axis_mask |= 0x8000; + } + return axis_mask; + } + + @Override + public int getButtonMask(InputDevice joystickDevice) { + int button_mask = 0; + int[] keys = new int[] { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_DPAD_CENTER, + + // These don't map into any SDL controller buttons directly + KeyEvent.KEYCODE_BUTTON_L2, + KeyEvent.KEYCODE_BUTTON_R2, + KeyEvent.KEYCODE_BUTTON_C, + KeyEvent.KEYCODE_BUTTON_Z, + KeyEvent.KEYCODE_BUTTON_1, + KeyEvent.KEYCODE_BUTTON_2, + KeyEvent.KEYCODE_BUTTON_3, + KeyEvent.KEYCODE_BUTTON_4, + KeyEvent.KEYCODE_BUTTON_5, + KeyEvent.KEYCODE_BUTTON_6, + KeyEvent.KEYCODE_BUTTON_7, + KeyEvent.KEYCODE_BUTTON_8, + KeyEvent.KEYCODE_BUTTON_9, + KeyEvent.KEYCODE_BUTTON_10, + KeyEvent.KEYCODE_BUTTON_11, + KeyEvent.KEYCODE_BUTTON_12, + KeyEvent.KEYCODE_BUTTON_13, + KeyEvent.KEYCODE_BUTTON_14, + KeyEvent.KEYCODE_BUTTON_15, + KeyEvent.KEYCODE_BUTTON_16, + }; + int[] masks = new int[] { + (1 << 0), // A -> A + (1 << 1), // B -> B + (1 << 2), // X -> X + (1 << 3), // Y -> Y + (1 << 4), // BACK -> BACK + (1 << 6), // MENU -> START + (1 << 5), // MODE -> GUIDE + (1 << 6), // START -> START + (1 << 7), // THUMBL -> LEFTSTICK + (1 << 8), // THUMBR -> RIGHTSTICK + (1 << 9), // L1 -> LEFTSHOULDER + (1 << 10), // R1 -> RIGHTSHOULDER + (1 << 11), // DPAD_UP -> DPAD_UP + (1 << 12), // DPAD_DOWN -> DPAD_DOWN + (1 << 13), // DPAD_LEFT -> DPAD_LEFT + (1 << 14), // DPAD_RIGHT -> DPAD_RIGHT + (1 << 4), // SELECT -> BACK + (1 << 0), // DPAD_CENTER -> A + (1 << 15), // L2 -> ?? + (1 << 16), // R2 -> ?? + (1 << 17), // C -> ?? + (1 << 18), // Z -> ?? + (1 << 20), // 1 -> ?? + (1 << 21), // 2 -> ?? + (1 << 22), // 3 -> ?? + (1 << 23), // 4 -> ?? + (1 << 24), // 5 -> ?? + (1 << 25), // 6 -> ?? + (1 << 26), // 7 -> ?? + (1 << 27), // 8 -> ?? + (1 << 28), // 9 -> ?? + (1 << 29), // 10 -> ?? + (1 << 30), // 11 -> ?? + (1 << 31), // 12 -> ?? + // We're out of room... + 0xFFFFFFFF, // 13 -> ?? + 0xFFFFFFFF, // 14 -> ?? + 0xFFFFFFFF, // 15 -> ?? + 0xFFFFFFFF, // 16 -> ?? + }; + boolean[] has_keys = joystickDevice.hasKeys(keys); + for (int i = 0; i < keys.length; ++i) { + if (has_keys[i]) { + button_mask |= masks[i]; + } + } + return button_mask; + } +} + +class SDLHapticHandler_API31 extends SDLHapticHandler { + @Override + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + vibrate(haptic.vib, intensity, length); + } + } + + @Override + public void rumble(int device_id, float low_frequency_intensity, float high_frequency_intensity, int length) { + InputDevice device = InputDevice.getDevice(device_id); + if (device == null) { + return; + } + + VibratorManager manager = device.getVibratorManager(); + int[] vibrators = manager.getVibratorIds(); + if (vibrators.length >= 2) { + vibrate(manager.getVibrator(vibrators[0]), low_frequency_intensity, length); + vibrate(manager.getVibrator(vibrators[1]), high_frequency_intensity, length); + } else if (vibrators.length == 1) { + float intensity = (low_frequency_intensity * 0.6f) + (high_frequency_intensity * 0.4f); + vibrate(manager.getVibrator(vibrators[0]), intensity, length); + } + } + + private void vibrate(Vibrator vibrator, float intensity, int length) { + if (intensity == 0.0f) { + vibrator.cancel(); + return; + } + + int value = Math.round(intensity * 255); + if (value > 255) { + value = 255; + } + if (value < 1) { + vibrator.cancel(); + return; + } + try { + vibrator.vibrate(VibrationEffect.createOneShot(length, value)); + } + catch (Exception e) { + // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if + // something went horribly wrong with the Android 8.0 APIs. + vibrator.vibrate(length); + } + } +} + +class SDLHapticHandler_API26 extends SDLHapticHandler { + @Override + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + if (intensity == 0.0f) { + stop(device_id); + return; + } + + int vibeValue = Math.round(intensity * 255); + + if (vibeValue > 255) { + vibeValue = 255; + } + if (vibeValue < 1) { + stop(device_id); + return; + } + try { + haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue)); + } + catch (Exception e) { + // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if + // something went horribly wrong with the Android 8.0 APIs. + haptic.vib.vibrate(length); + } + } + } +} + +class SDLHapticHandler { + + static class SDLHaptic { + public int device_id; + public String name; + public Vibrator vib; + } + + private final ArrayList mHaptics; + + public SDLHapticHandler() { + mHaptics = new ArrayList(); + } + + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + haptic.vib.vibrate(length); + } + } + + public void rumble(int device_id, float low_frequency_intensity, float high_frequency_intensity, int length) { + // Not supported in older APIs + } + + public void stop(int device_id) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + haptic.vib.cancel(); + } + } + + public void pollHapticDevices() { + + final int deviceId_VIBRATOR_SERVICE = 999999; + boolean hasVibratorService = false; + + /* Check VIBRATOR_SERVICE */ + Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE); + if (vib != null) { + hasVibratorService = vib.hasVibrator(); + + if (hasVibratorService) { + SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE); + if (haptic == null) { + haptic = new SDLHaptic(); + haptic.device_id = deviceId_VIBRATOR_SERVICE; + haptic.name = "VIBRATOR_SERVICE"; + haptic.vib = vib; + mHaptics.add(haptic); + SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name); + } + } + } + + /* Check removed devices */ + ArrayList removedDevices = null; + for (SDLHaptic haptic : mHaptics) { + int device_id = haptic.device_id; + if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) { + if (removedDevices == null) { + removedDevices = new ArrayList(); + } + removedDevices.add(device_id); + } // else: don't remove the vibrator if it is still present + } + + if (removedDevices != null) { + for (int device_id : removedDevices) { + SDLControllerManager.nativeRemoveHaptic(device_id); + for (int i = 0; i < mHaptics.size(); i++) { + if (mHaptics.get(i).device_id == device_id) { + mHaptics.remove(i); + break; + } + } + } + } + } + + protected SDLHaptic getHaptic(int device_id) { + for (SDLHaptic haptic : mHaptics) { + if (haptic.device_id == device_id) { + return haptic; + } + } + return null; + } +} + +class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener { + // Generic Motion (mouse hover, joystick...) events go here + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + float x, y; + int action; + + switch ( event.getSource() ) { + case InputDevice.SOURCE_JOYSTICK: + return SDLControllerManager.handleJoystickMotionEvent(event); + + case InputDevice.SOURCE_MOUSE: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLService.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + + SDLService.onNativeMouse(0, action, x, y, false); + return true; + + default: + break; + } + break; + + default: + break; + } + + // Event was not managed + return false; + } + + public boolean supportsRelativeMouse() { + return false; + } + + public boolean inRelativeMode() { + return false; + } + + public boolean setRelativeMouseEnabled(boolean enabled) { + return false; + } + + public void reclaimRelativeMouseModeIfNeeded() + { + + } + + public float getEventX(MotionEvent event) { + return event.getX(0); + } + + public float getEventY(MotionEvent event) { + return event.getY(0); + } + +} + +class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 { + // Generic Motion (mouse hover, joystick...) events go here + + private boolean mRelativeModeEnabled; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + + // Handle relative mouse mode + if (mRelativeModeEnabled) { + if (event.getSource() == InputDevice.SOURCE_MOUSE) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_HOVER_MOVE) { + float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + SDLService.onNativeMouse(0, action, x, y, true); + return true; + } + } + } + + // Event was not managed, call SDLGenericMotionListener_API12 method + return super.onGenericMotion(v, event); + } + + @Override + public boolean supportsRelativeMouse() { + return true; + } + + @Override + public boolean inRelativeMode() { + return mRelativeModeEnabled; + } + + @Override + public boolean setRelativeMouseEnabled(boolean enabled) { + mRelativeModeEnabled = enabled; + return true; + } + + @Override + public float getEventX(MotionEvent event) { + if (mRelativeModeEnabled) { + return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + } else { + return event.getX(0); + } + } + + @Override + public float getEventY(MotionEvent event) { + if (mRelativeModeEnabled) { + return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + } else { + return event.getY(0); + } + } +} + +class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 { + // Generic Motion (mouse hover, joystick...) events go here + private boolean mRelativeModeEnabled; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + float x, y; + int action; + + switch ( event.getSource() ) { + case InputDevice.SOURCE_JOYSTICK: + return SDLControllerManager.handleJoystickMotionEvent(event); + + case InputDevice.SOURCE_MOUSE: + // DeX desktop mouse cursor is a separate non-standard input type. + case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLService.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + SDLService.onNativeMouse(0, action, x, y, false); + return true; + + default: + break; + } + break; + + case InputDevice.SOURCE_MOUSE_RELATIVE: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLService.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + SDLService.onNativeMouse(0, action, x, y, true); + return true; + + default: + break; + } + break; + + default: + break; + } + + // Event was not managed + return false; + } + + @Override + public boolean supportsRelativeMouse() { + return (!SDLService.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */); + } + + @Override + public boolean inRelativeMode() { + return mRelativeModeEnabled; + } + + @Override + public boolean setRelativeMouseEnabled(boolean enabled) { + if (!SDLService.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */) { + if (enabled) { + SDLService.getContentView().requestPointerCapture(); + } else { + SDLService.getContentView().releasePointerCapture(); + } + mRelativeModeEnabled = enabled; + return true; + } else { + return false; + } + } + + @Override + public void reclaimRelativeMouseModeIfNeeded() + { + if (mRelativeModeEnabled && !SDLService.isDeXMode()) { + SDLService.getContentView().requestPointerCapture(); + } + } + + @Override + public float getEventX(MotionEvent event) { + // Relative mouse in capture mode will only have relative for X/Y + return event.getX(0); + } + + @Override + public float getEventY(MotionEvent event) { + // Relative mouse in capture mode will only have relative for X/Y + return event.getY(0); + } +} diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLDummyEdit.java b/android-project/app/src/main/java/org/libsdl/app/SDLDummyEdit.java new file mode 100644 index 0000000..15310e6 --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/SDLDummyEdit.java @@ -0,0 +1,66 @@ +package org.libsdl.app; + +import android.content.*; +import android.text.InputType; +import android.view.*; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +/* This is a fake invisible editor view that receives the input and defines the + * pan&scan region + */ +public class SDLDummyEdit extends View implements View.OnKeyListener +{ + InputConnection ic; + int input_type; + + public SDLDummyEdit(Context context) { + super(context); + setFocusableInTouchMode(true); + setFocusable(true); + setOnKeyListener(this); + } + + public void setInputType(int input_type) { + this.input_type = input_type; + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + return SDLService.handleKeyEvent(v, keyCode, event, ic); + } + + // + @Override + public boolean onKeyPreIme (int keyCode, KeyEvent event) { + // As seen on StackOverflow: http://stackoverflow.com/questions/7634346/keyboard-hide-event + // FIXME: Discussion at http://bugzilla.libsdl.org/show_bug.cgi?id=1639 + // FIXME: This is not a 100% effective solution to the problem of detecting if the keyboard is showing or not + // FIXME: A more effective solution would be to assume our Layout to be RelativeLayout or LinearLayout + // FIXME: And determine the keyboard presence doing this: http://stackoverflow.com/questions/2150078/how-to-check-visibility-of-software-keyboard-in-android + // FIXME: An even more effective way would be if Android provided this out of the box, but where would the fun be in that :) + if (event.getAction()==KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + if (SDLService.mTextEdit != null && SDLService.mTextEdit.getVisibility() == View.VISIBLE) { + SDLService.onNativeKeyboardFocusLost(); + } + } + return super.onKeyPreIme(keyCode, event); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + ic = new SDLInputConnection(this, true); + + outAttrs.inputType = input_type; + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI | + EditorInfo.IME_FLAG_NO_FULLSCREEN /* API 11 */; + + return ic; + } +} + diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLInputConnection.java b/android-project/app/src/main/java/org/libsdl/app/SDLInputConnection.java new file mode 100644 index 0000000..19a0cc3 --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/SDLInputConnection.java @@ -0,0 +1,136 @@ +package org.libsdl.app; + +import android.content.*; +import android.os.Build; +import android.text.Editable; +import android.view.*; +import android.view.inputmethod.BaseInputConnection; +import android.widget.EditText; + +public class SDLInputConnection extends BaseInputConnection +{ + protected EditText mEditText; + protected String mCommittedText = ""; + + public SDLInputConnection(View targetView, boolean fullEditor) { + super(targetView, fullEditor); + mEditText = new EditText(SDL.getContext()); + } + + @Override + public Editable getEditable() { + return mEditText.getEditableText(); + } + + @Override + public boolean sendKeyEvent(KeyEvent event) { + /* + * This used to handle the keycodes from soft keyboard (and IME-translated input from hardkeyboard) + * However, as of Ice Cream Sandwich and later, almost all soft keyboard doesn't generate key presses + * and so we need to generate them ourselves in commitText. To avoid duplicates on the handful of keys + * that still do, we empty this out. + */ + + /* + * Return DOES still generate a key event, however. So rather than using it as the 'click a button' key + * as we do with physical keyboards, let's just use it to hide the keyboard. + */ + + if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { + if (SDLService.onNativeSoftReturnKey()) { + return true; + } + } + + return super.sendKeyEvent(event); + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (!super.commitText(text, newCursorPosition)) { + return false; + } + updateText(); + return true; + } + + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + if (!super.setComposingText(text, newCursorPosition)) { + return false; + } + updateText(); + return true; + } + + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + if (Build.VERSION.SDK_INT <= 29 /* Android 10.0 (Q) */) { + // Workaround to capture backspace key. Ref: http://stackoverflow.com/questions>/14560344/android-backspace-in-webview-baseinputconnection + // and https://bugzilla.libsdl.org/show_bug.cgi?id=2265 + if (beforeLength > 0 && afterLength == 0) { + // backspace(s) + while (beforeLength-- > 0) { + nativeGenerateScancodeForUnichar('\b'); + } + return true; + } + } + + if (!super.deleteSurroundingText(beforeLength, afterLength)) { + return false; + } + updateText(); + return true; + } + + protected void updateText() { + final Editable content = getEditable(); + if (content == null) { + return; + } + + String text = content.toString(); + int compareLength = Math.min(text.length(), mCommittedText.length()); + int matchLength, offset; + + /* Backspace over characters that are no longer in the string */ + for (matchLength = 0; matchLength < compareLength; ) { + int codePoint = mCommittedText.codePointAt(matchLength); + if (codePoint != text.codePointAt(matchLength)) { + break; + } + matchLength += Character.charCount(codePoint); + } + /* FIXME: This doesn't handle graphemes, like '🌬️' */ + for (offset = matchLength; offset < mCommittedText.length(); ) { + int codePoint = mCommittedText.codePointAt(offset); + nativeGenerateScancodeForUnichar('\b'); + offset += Character.charCount(codePoint); + } + + if (matchLength < text.length()) { + String pendingText = text.subSequence(matchLength, text.length()).toString(); + for (offset = 0; offset < pendingText.length(); ) { + int codePoint = pendingText.codePointAt(offset); + if (codePoint == '\n') { + if (SDLService.onNativeSoftReturnKey()) { + return; + } + } + /* Higher code points don't generate simulated scancodes */ + if (codePoint < 128) { + nativeGenerateScancodeForUnichar((char)codePoint); + } + offset += Character.charCount(codePoint); + } + SDLInputConnection.nativeCommitText(pendingText, 0); + } + mCommittedText = text; + } + + public static native void nativeCommitText(String text, int newCursorPosition); + + public static native void nativeGenerateScancodeForUnichar(char c); +} + diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLService.java b/android-project/app/src/main/java/org/libsdl/app/SDLService.java new file mode 100644 index 0000000..653c53c --- /dev/null +++ b/android-project/app/src/main/java/org/libsdl/app/SDLService.java @@ -0,0 +1,2331 @@ +package org.libsdl.app; + +//import android.app.Activity; +import android.app.Service; +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.os.Process; +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.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; + +import android.os.IBinder; +import android.graphics.PixelFormat; +import android.widget.FrameLayout; + +import android.view.TextureView; +import android.view.MotionEvent; +import android.os.Looper; + +import android.content.BroadcastReceiver; +import android.content.IntentFilter; + +/** + SDL Service +*/ +public class SDLService extends Service implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 3; + private static final int SDL_MINOR_VERSION = 1; + private static final int SDL_MICRO_VERSION = 7; +/* + // 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; + + if (Build.VERSION.SDK_INT >= 21) { + 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; + + if (Build.VERSION.SDK_INT >= 18) { + 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_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 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_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT = 12; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOP = 13; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT = 14; + private static final int SDL_SYSTEM_CURSOR_WINDOW_RIGHT = 15; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT = 16; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOM = 17; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT = 18; + private static final int SDL_SYSTEM_CURSOR_WINDOW_LEFT = 19; + + 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 SDLService mSingleton; + protected static SDLSurface mSurface; + protected static SDLDummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + //protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API12 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; + + // Views that are moved from native side + private WindowManager wm; + //private View animal_view, options_view; + //private WindowManager.LayoutParams animal_params, options_params; + private static WindowManager.LayoutParams layoutParams; + private BroadcastReceiver screenReceiver; + + protected static SDLGenericMotionListener_API12 getMotionListener() { + if (mMotionListener == null) { + 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_API12(); + } + } + + 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 = SDLService.mSingleton.getMainSharedObject(); + String function = SDLService.mSingleton.getMainFunction(); + String[] arguments = SDLService.mSingleton.getArguments(); + + Log.v("SDL", "Running main function " + function + " from library " + library); + SDLService.nativeRunMain(library, function, arguments); + Log.v("SDL", "Finished main function"); + stopSelf(); + } + + /** + * 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 = SDLService.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 + public void onCreate() { + Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER); + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(); + + /* Control activity re-creation */ + /*if (mSDLMainFinished || mActivityCreated) { + boolean allow_recreate = SDLService.nativeAllowRecreateService(); + 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 + //SDLService.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 = SDLService.nativeCheckSDLThreadCounter(); /* get and increment a native counter */ +// if (run_count != 0) { +// boolean allow_recreate = SDLService.nativeAllowRecreateService(); +// 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); + + //mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + wm = (WindowManager) getSystemService(WINDOW_SERVICE); + + + // Set up the surface + mSurface = createSDLSurface(this); + + layoutParams = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Requires SYSTEM_ALERT_WINDOW + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ); + + layoutParams.gravity = Gravity.TOP | Gravity.LEFT; + + wm.addView(mSurface, layoutParams); + + // Get our current screen orientation and pass it down. + //SDLService.nativeSetNaturalOrientation(SDLService.getNaturalOrientation()); + //mCurrentRotation = SDLService.getCurrentRotation(); + //SDLService.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: + //SDLService.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + //SDLService.onNativeDarkModeChanged(true); + break; + } + + 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); + SDLService.onNativeDropFile(filename); + } + }*/ + + mIsResumedCalled = true; + + registerScreenReceiver(); + } + + // Called from C++ via JNI + public void updateViewsFromNative(int x1, int y1, int w1, int h1) { + if (wm == null || mSurface == null) { + Log.e("SDLService", "WindowManager or views are not initialized yet!"); + return; + } + + // Post to UI thread + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> { + try { + layoutParams.x = x1; + layoutParams.y = y1; + layoutParams.width = w1 == -1 ? WindowManager.LayoutParams.MATCH_PARENT : w1; + layoutParams.height = h1 == -1 ? WindowManager.LayoutParams.MATCH_PARENT : h1; + wm.updateViewLayout(mSurface, layoutParams); + } catch (Exception e) { + Log.e("SDLService", "Error updating views", e); + } + }); + } + + public static WindowManager.LayoutParams getLayoutParams() { + return layoutParams; + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLService.mBrokenLibraries) { + return; + } + + SDLService.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLService.mBrokenLibraries) { + return; + } + + SDLService.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 = (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 = (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 (SDLService.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + //SDLService.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLService.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLService.handleNativeState(); + } + } + }*/ + + @Override + public void onTrimMemory(int level) { + Log.v(TAG, "onTrimMemory()"); + super.onTrimMemory(level); + + if (SDLService.mBrokenLibraries) { + return; + } + + //SDLService.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLService.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLService.onNativeLocaleChanged(); + } + + switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLService.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLService.onNativeDarkModeChanged(true); + break; + } + } + + @Override + public void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLService.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLService.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLService.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. + SDLService.mSDLThread.join(1000); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLService.nativeQuit(); + + WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); + wm.removeView(mSurface); + + stopForeground(true); + super.onDestroy(); + + if (screenReceiver != null) { + unregisterReceiver(screenReceiver); + } + + // Seems that native code stays in memory if process is not killed. + // Nativce code SDL_PollEvent then somehow gets stuck when app is + // run on a second time. This fixes it, but I read that this is bad. + // So maybe should inf a better alternative. + Process.killProcess(Process.myPid()); + } + + /* @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 = SDLService.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(); + } + } */ + +// @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) { +// 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 { +// /* 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 (!SDLService.this.isFinishing()) { + SDLService.this.superOnBackPressed(); + } + } + });*/ + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + //super.onBackPressed(); + } + +// @Override +// public boolean dispatchKeyEvent(KeyEvent event) { +// +// if (SDLService.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; +// } +// return super.dispatchKeyEvent(event); +// } + + /* Transition to next state */ + public static void handleNativeState() { + Log.v(TAG, "HERE1.5"); + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + Log.v(TAG, "HERE1.6"); + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + Log.v(TAG, "HERE1.7"); + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + Log.v(TAG, "HERE2"); + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + Log.v(TAG, "HERE2.1"); + 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); + Log.v(TAG, "HERE3"); + 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 = SDL.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 (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { +// if (context instanceof Activity) { +// Window window = ((Activity) context).getWindow(); +// if (window != null) { +// if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { +// 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 >= 28 /* Android 9 (Pie) */) { +// window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; +// } +// } +// } 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); + + mScreenKeyboardShown = false; + + 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) { + if (true) { + return true; + } + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + 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 && (SDLService.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 (SDLService.getContext()) { + try { + SDLService.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int 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 onNativePen(int penId, int button, int action, float x, float y, float p); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + 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); + + // Non-SDL: + public static native void passRawEventPos(float x, float y); + public static native void setSleepState(int should_sleep); + + /** + * 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 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 isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * 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 SDLService.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLService.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 Context 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.equals("X96-W")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV")) { + 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; + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + /*Activity 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));*/ + return 0; + } + + /** + * 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() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * 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(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + mTextEdit.setInputType(input_type); + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * 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(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + 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(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode)) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode)) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + if (SDLService.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)) { + Log.v("SDL", "keycode is back or 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) { + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + onNativeKeyDown(keyCode); + 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 (SDLService.mSurface == null) { + return null; + } + return SDLService.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