Слияние feat/android: APK под Android 12+, сенсорное управление
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
Из ветки feat/android: - клиент разделён на lib.rs (общий запуск) и main.rs; на Android собирается cdylib с экспортом SDL_main, паника ловится catch_unwind на FFI-границе; - steamworks и сетевая команда отключены для Android, Lua — 5.4 vendored (LuaJIT не кросс-собирается), на десктопе остался LuaJIT; - SDL3 собирается из исходников, Java-обвязка SDLActivity + MainActivity (ландшафт, путь к ассетам через getExternalFilesDir); - сенсорное управление: левый стик — ходьба, правая половина — обзор, кнопки прыжка/приседа; короткий тап не теряется между кадрами; - android/build.sh собирает APK без Gradle (cargo + aapt2 + d8 + zipalign + apksigner), поддерживает ANDROID_ABI=x86_64; - docs/ANDROID.md и android/README.md. APK: target/android/gmod-rs-debug.apk (arm64-v8a, minSdk 31, targetSdk 35). Проверено на эмуляторе API 35 x86_64: установка, загрузка gm_construct, стик, свайп-обзор, прыжок. Десктопные 25 сьютов и clippy — зелёные.
This commit is contained in:
commit
2edea0125f
26 changed files with 7117 additions and 165 deletions
2
android/.gitignore
vendored
Normal file
2
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
build/
|
||||
debug.keystore
|
||||
48
android/AndroidManifest.xml
Normal file
48
android/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Манифест Android-клиента gmod-rs.
|
||||
|
||||
Активность — штатная SDL3 (org.libsdl.app.SDLActivity): она грузит
|
||||
libSDL3.so и libmain.so и вызывает из неё SDL_main. Наши .so кладёт в APK
|
||||
скрипт android/build.sh.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.gmodrs.client"
|
||||
android:versionCode="1"
|
||||
android:versionName="0.1.0"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-sdk android:minSdkVersion="31" android:targetSdkVersion="35" />
|
||||
|
||||
<!-- OpenGL ES 2.0 достаточно для GLES-бэкенда wgpu; Vulkan проверяется
|
||||
самим wgpu при старте. -->
|
||||
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x00400000" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="gmod-rs"
|
||||
android:allowBackup="false"
|
||||
android:extractNativeLibs="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
|
||||
|
||||
<activity
|
||||
android:name="org.gmodrs.client.MainActivity"
|
||||
android:label="gmod-rs"
|
||||
android:screenOrientation="landscape"
|
||||
android:alwaysRetainTaskState="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="layoutDirection|locale|grammaticalGender|fontScale|fontWeightAdjustment|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
76
android/README.md
Normal file
76
android/README.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Android-клиент gmod-rs
|
||||
|
||||
APK под Android 12+ (API 31+), arm64-v8a. Нативный клиент — тот же крейт
|
||||
`client`, что и на десктопе: SDL3 + wgpu. Для Android он собирается как
|
||||
`cdylib` (`libmain.so`) с экспортом `SDL_main`; Java-обвязка — штатная
|
||||
`SDLActivity` из SDL3 (zlib-лицензия, файлы лежат в
|
||||
`app/src/main/java/org/libsdl/app`).
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
./android/build.sh
|
||||
```
|
||||
|
||||
Нужны: Android SDK (platform-tools, platforms;android-31+, build-tools,
|
||||
`android.jar`), NDK (проверено на r29), cmake, Rust с таргетом
|
||||
`aarch64-linux-android`. Скрипт сам находит SDK/NDK (`ANDROID_HOME`,
|
||||
`ANDROID_NDK_HOME` или `/opt/android-sdk`, `/opt/android-ndk`), собирает
|
||||
`libclient.so` + `libSDL3.so`, пакует и подписывает debug-ключом
|
||||
(`android/debug.keystore`, создаётся при первом запуске).
|
||||
|
||||
Результат: `target/android/gmod-rs-debug.apk`.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
adb install -r target/android/gmod-rs-debug.apk
|
||||
```
|
||||
|
||||
Ассеты GMod (каталог `server` из установки/DS-сервера) копируются во внешний
|
||||
каталог приложения:
|
||||
|
||||
```bash
|
||||
adb shell mkdir -p /sdcard/Android/data/org.gmodrs.client/files/gmod_server
|
||||
adb push ~/gmod_server/server /sdcard/Android/data/org.gmodrs.client/files/gmod_server/
|
||||
# adb создаёт каталоги от shell, а приложению нужен доступ:
|
||||
adb shell chmod -R 777 /sdcard/Android/data/org.gmodrs.client/files/gmod_server
|
||||
```
|
||||
|
||||
Приложение по умолчанию открывает `gm_construct` и ищет ассеты в
|
||||
`/sdcard/Android/data/org.gmodrs.client/files/gmod_server/server` (точный путь
|
||||
передаёт `MainActivity` через `getExternalFilesDir`). Другой путь можно задать
|
||||
переменной `GMOD_RS_ASSETS` — но на телефоне переменные окружения задать
|
||||
непросто, поэтому проще положить ассеты по стандартному пути.
|
||||
|
||||
Минимально для `gm_construct` достаточно `garrysmod/` (карты и контент игры) и
|
||||
`sourceengine/` (текстуры/модели HL2); `platform/` нужен для части эффектов.
|
||||
Проверено на усечённом наборе (garrysmod VPK + карта): карта грузится и
|
||||
рисуется, часть материалов заменяется заглушкой.
|
||||
|
||||
## Запуск и логи
|
||||
|
||||
```bash
|
||||
adb shell am start -n org.gmodrs.client/.MainActivity
|
||||
adb logcat -s gmod-rs SDL
|
||||
```
|
||||
|
||||
## Управление
|
||||
|
||||
- левая половина экрана — виртуальный стик (ходьба, сразу бегом);
|
||||
- правая половина — свайп обзора;
|
||||
- кнопки `JUMP` и `DUCK` справа снизу;
|
||||
- системная кнопка «назад» завершает приложение (SDL присылает Escape).
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Steam/сеть отключены на Android: `steamworks` не собирается под эту цель,
|
||||
команда `net` недоступна.
|
||||
- Lua на Android — Lua 5.4 (vendored), а не LuaJIT: кросс-сборка LuaJIT под
|
||||
Android в mlua не поддерживается. API GMod-скриптов тот же.
|
||||
- Собран и проверен `arm64-v8a` (APK для телефона) и `x86_64` (эмулятор).
|
||||
Проверка в эмуляторе API 35: карта грузится, кадры рисуются через
|
||||
SwiftShader (11–14 fps на софтверном GPU), стик/обзор/прыжок работают.
|
||||
На реальном arm64-устройстве не запускалось — проверьте `adb logcat`.
|
||||
- Для эмулятора: `ANDROID_ABI=x86_64 ./android/build.sh`, результат —
|
||||
`target/android/gmod-rs-debug-x86_64.apk`.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.gmodrs.client;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Активность клиента gmod-rs.
|
||||
*
|
||||
* Наследует штатную SDLActivity и передаёт нативной части путь к ассетам:
|
||||
* внешний каталог приложения (`Android/data/org.gmodrs.client/files`) доступен
|
||||
* без разрешений на хранилище, в отличие от прямого `/sdcard`.
|
||||
*/
|
||||
public class MainActivity extends org.libsdl.app.SDLActivity {
|
||||
/**
|
||||
* SDL при создании окна запрашивает ориентацию из размеров окна и флага
|
||||
* resizable (FULL_USER), перебивая манифест. Для игры фиксируем ландшафт.
|
||||
*/
|
||||
@Override
|
||||
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
|
||||
setRequestedOrientation(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String[] getArguments() {
|
||||
File assets = new File(getExternalFilesDir(null), "gmod_server/server");
|
||||
android.util.Log.i("gmod-rs", "assets=" + assets.getAbsolutePath()
|
||||
+ " exists=" + assets.isDirectory()
|
||||
+ " external=" + getExternalFilesDir(null));
|
||||
return new String[] {
|
||||
"map",
|
||||
"gm_construct",
|
||||
"--assets",
|
||||
assets.getAbsolutePath()
|
||||
};
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/java/org/libsdl/app/HIDDevice.java
Normal file
21
android/app/src/main/java/org/libsdl/app/HIDDevice.java
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -0,0 +1,829 @@
|
|||
package org.libsdl.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCallback;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.bluetooth.BluetoothGattDescriptor;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
import android.bluetooth.BluetoothGattService;
|
||||
import android.hardware.usb.UsbDevice;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.os.*;
|
||||
|
||||
//import com.android.internal.util.HexDump;
|
||||
|
||||
import java.lang.Runnable;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.UUID;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
|
||||
|
||||
private static final String TAG = "hidapi";
|
||||
private HIDDeviceManager mManager;
|
||||
private BluetoothDevice mDevice;
|
||||
private int mDeviceId;
|
||||
private BluetoothGatt mGatt;
|
||||
private boolean mIsRegistered = false;
|
||||
private boolean mIsConnected = false;
|
||||
private boolean mIsChromebook = false;
|
||||
private boolean mIsReconnecting = false;
|
||||
private boolean mHasEnabledNotifications = false;
|
||||
private boolean mHasSeenInputUpdate = false;
|
||||
private boolean mFrozen = false;
|
||||
private LinkedList<GattOperation> mOperations;
|
||||
GattOperation mCurrentOperation = null;
|
||||
private Handler mHandler;
|
||||
private int mProductId = -1;
|
||||
private int mReportId = 0;
|
||||
private UUID mInputCharacteristic;
|
||||
|
||||
private static final int D0G_BLE2_PID = 0x1106;
|
||||
private static final int TRITON_BLE_PID = 0x1303;
|
||||
|
||||
|
||||
private static final int TRANSPORT_AUTO = 0;
|
||||
private static final int TRANSPORT_BREDR = 1;
|
||||
private static final int TRANSPORT_LE = 2;
|
||||
|
||||
private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
|
||||
|
||||
static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
|
||||
static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
|
||||
static final UUID inputCharacteristicTriton_0x45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3");
|
||||
static final UUID inputCharacteristicTriton_0x47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3");
|
||||
static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
|
||||
static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
|
||||
|
||||
private HashMap<Integer, BluetoothGattCharacteristic> mOutputReportChars = new HashMap<Integer, BluetoothGattCharacteristic>();
|
||||
|
||||
static class GattOperation {
|
||||
private enum Operation {
|
||||
CHR_READ,
|
||||
CHR_WRITE,
|
||||
ENABLE_NOTIFICATION
|
||||
}
|
||||
|
||||
Operation mOp;
|
||||
UUID mUuid;
|
||||
byte[] mValue;
|
||||
BluetoothGatt mGatt;
|
||||
boolean mResult = true;
|
||||
int mDelayMs = 0;
|
||||
|
||||
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
|
||||
mGatt = gatt;
|
||||
mOp = operation;
|
||||
mUuid = uuid;
|
||||
}
|
||||
|
||||
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, int delayMs) {
|
||||
mGatt = gatt;
|
||||
mOp = operation;
|
||||
mUuid = uuid;
|
||||
mDelayMs = delayMs;
|
||||
}
|
||||
|
||||
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
|
||||
mGatt = gatt;
|
||||
mOp = operation;
|
||||
mUuid = uuid;
|
||||
mValue = value;
|
||||
}
|
||||
|
||||
private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value, int delayMs) {
|
||||
mGatt = gatt;
|
||||
mOp = operation;
|
||||
mUuid = uuid;
|
||||
mValue = value;
|
||||
mDelayMs = delayMs;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
// This is executed in main thread
|
||||
BluetoothGattCharacteristic chr;
|
||||
|
||||
switch (mOp) {
|
||||
case CHR_READ:
|
||||
chr = getCharacteristic(mUuid);
|
||||
//Log.v(TAG, "Reading characteristic " + chr.getUuid());
|
||||
if (!mGatt.readCharacteristic(chr)) {
|
||||
Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
|
||||
mResult = false;
|
||||
break;
|
||||
}
|
||||
mResult = true;
|
||||
break;
|
||||
case CHR_WRITE:
|
||||
chr = getCharacteristic(mUuid);
|
||||
//Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
|
||||
chr.setValue(mValue);
|
||||
if (!mGatt.writeCharacteristic(chr)) {
|
||||
Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
|
||||
mResult = false;
|
||||
break;
|
||||
}
|
||||
mResult = true;
|
||||
break;
|
||||
case ENABLE_NOTIFICATION:
|
||||
chr = getCharacteristic(mUuid);
|
||||
//Log.v(TAG, "Writing descriptor of " + chr.getUuid());
|
||||
if (chr != null) {
|
||||
BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
|
||||
if (cccd != null) {
|
||||
int properties = chr.getProperties();
|
||||
byte[] value;
|
||||
if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
|
||||
value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
|
||||
} else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
|
||||
value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
|
||||
} else {
|
||||
Log.e(TAG, "Unable to start notifications on input characteristic");
|
||||
mResult = false;
|
||||
return;
|
||||
}
|
||||
|
||||
mGatt.setCharacteristicNotification(chr, true);
|
||||
cccd.setValue(value);
|
||||
if (!mGatt.writeDescriptor(cccd)) {
|
||||
Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
|
||||
mResult = false;
|
||||
return;
|
||||
}
|
||||
mResult = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean finish() {
|
||||
return mResult;
|
||||
}
|
||||
|
||||
public int getDelayMs() { return mDelayMs; }
|
||||
|
||||
private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
|
||||
BluetoothGattService valveService = mGatt.getService(steamControllerService);
|
||||
if (valveService == null)
|
||||
return null;
|
||||
return valveService.getCharacteristic(uuid);
|
||||
}
|
||||
|
||||
static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
|
||||
return new GattOperation(gatt, Operation.CHR_READ, uuid);
|
||||
}
|
||||
|
||||
static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
|
||||
return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
|
||||
}
|
||||
|
||||
static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
|
||||
return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
|
||||
}
|
||||
|
||||
static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid, int delayMs) {
|
||||
return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
|
||||
mManager = manager;
|
||||
mDevice = device;
|
||||
mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
|
||||
mIsRegistered = false;
|
||||
mIsChromebook = SDLActivity.isChromebook();
|
||||
mOperations = new LinkedList<GattOperation>();
|
||||
mHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
mGatt = connectGatt();
|
||||
mHasEnabledNotifications = false;
|
||||
mHasSeenInputUpdate = false;
|
||||
// final HIDDeviceBLESteamController finalThis = this;
|
||||
// mHandler.postDelayed(new Runnable() {
|
||||
// @Override
|
||||
// void run() {
|
||||
// finalThis.checkConnectionForChromebookIssue();
|
||||
// }
|
||||
// }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
String getIdentifier() {
|
||||
return String.format("SteamController.%s", mDevice.getAddress());
|
||||
}
|
||||
|
||||
BluetoothGatt getGatt() {
|
||||
return mGatt;
|
||||
}
|
||||
|
||||
// Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
|
||||
// of TRANSPORT_LE. Let's force ourselves to connect low energy.
|
||||
private BluetoothGatt connectGatt(boolean managed) {
|
||||
if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
|
||||
try {
|
||||
return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
|
||||
} catch (Exception e) {
|
||||
return mDevice.connectGatt(mManager.getContext(), managed, this);
|
||||
}
|
||||
} else {
|
||||
return mDevice.connectGatt(mManager.getContext(), managed, this);
|
||||
}
|
||||
}
|
||||
|
||||
private BluetoothGatt connectGatt() {
|
||||
return connectGatt(false);
|
||||
}
|
||||
|
||||
protected int getConnectionState() {
|
||||
|
||||
Context context = mManager.getContext();
|
||||
if (context == null) {
|
||||
// We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
|
||||
return BluetoothProfile.STATE_DISCONNECTED;
|
||||
}
|
||||
|
||||
BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
if (btManager == null) {
|
||||
// This device doesn't support Bluetooth. We should never be here, because how did
|
||||
// we instantiate a device to start with?
|
||||
return BluetoothProfile.STATE_DISCONNECTED;
|
||||
}
|
||||
|
||||
return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
|
||||
}
|
||||
|
||||
void reconnect() {
|
||||
|
||||
if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
|
||||
mGatt.disconnect();
|
||||
mGatt = connectGatt();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void checkConnectionForChromebookIssue() {
|
||||
if (!mIsChromebook) {
|
||||
// We only do this on Chromebooks, because otherwise it's really annoying to just attempt
|
||||
// over and over.
|
||||
return;
|
||||
}
|
||||
|
||||
int connectionState = getConnectionState();
|
||||
|
||||
switch (connectionState) {
|
||||
case BluetoothProfile.STATE_CONNECTED:
|
||||
if (!mIsConnected) {
|
||||
// We are in the Bad Chromebook Place. We can force a disconnect
|
||||
// to try to recover.
|
||||
Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
|
||||
mIsReconnecting = true;
|
||||
mGatt.disconnect();
|
||||
mGatt = connectGatt(false);
|
||||
break;
|
||||
}
|
||||
else if (!isRegistered()) {
|
||||
if (mGatt.getServices().size() > 0) {
|
||||
Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
|
||||
probeService(this);
|
||||
}
|
||||
else {
|
||||
Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
|
||||
mIsReconnecting = true;
|
||||
mGatt.disconnect();
|
||||
mGatt = connectGatt(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else {
|
||||
Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
case BluetoothProfile.STATE_DISCONNECTED:
|
||||
Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
|
||||
|
||||
mIsReconnecting = true;
|
||||
mGatt.disconnect();
|
||||
mGatt = connectGatt(false);
|
||||
break;
|
||||
|
||||
case BluetoothProfile.STATE_CONNECTING:
|
||||
Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
|
||||
break;
|
||||
}
|
||||
|
||||
final HIDDeviceBLESteamController finalThis = this;
|
||||
mHandler.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
finalThis.checkConnectionForChromebookIssue();
|
||||
}
|
||||
}, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
private boolean isRegistered() {
|
||||
return mIsRegistered;
|
||||
}
|
||||
|
||||
private void setRegistered() {
|
||||
mIsRegistered = true;
|
||||
}
|
||||
|
||||
private boolean probeService(HIDDeviceBLESteamController controller) {
|
||||
|
||||
if (isRegistered()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!mIsConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.v(TAG, "probeService controller=" + controller);
|
||||
|
||||
for (BluetoothGattService service : mGatt.getServices()) {
|
||||
if (service.getUuid().equals(steamControllerService)) {
|
||||
Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
|
||||
|
||||
for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
|
||||
if (chr.getUuid().equals(inputCharacteristicTriton_0x45)) {
|
||||
Log.v(TAG, "Found Triton input characteristic 0x45");
|
||||
mProductId = TRITON_BLE_PID;
|
||||
mReportId = 0x45;
|
||||
mInputCharacteristic = chr.getUuid();
|
||||
} else if (chr.getUuid().equals(inputCharacteristicTriton_0x47)) {
|
||||
Log.v(TAG, "Found Triton input characteristic 0x47");
|
||||
mProductId = TRITON_BLE_PID;
|
||||
mReportId = 0x47;
|
||||
mInputCharacteristic = chr.getUuid();
|
||||
} else if (chr.getUuid().equals(inputCharacteristicD0G)) {
|
||||
Log.v(TAG, "Found D0G input characteristic");
|
||||
mProductId = D0G_BLE2_PID;
|
||||
mReportId = 0x03;
|
||||
mInputCharacteristic = chr.getUuid();
|
||||
} else {
|
||||
Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = reportPattern.matcher(chr.getUuid().toString());
|
||||
|
||||
if (matcher.find()) {
|
||||
try {
|
||||
int reportId = Integer.parseInt(matcher.group(1), 16);
|
||||
|
||||
reportId -= 0x35;
|
||||
if (reportId >= 0x80) {
|
||||
// This is a Triton output report characteristic that we need to care about.
|
||||
Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16));
|
||||
mOutputReportChars.put(reportId, chr);
|
||||
}
|
||||
}
|
||||
catch (NumberFormatException nfe) {
|
||||
Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
|
||||
if (chr.getUuid().equals(mInputCharacteristic)) {
|
||||
// Start notifications
|
||||
BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
|
||||
if (cccd != null) {
|
||||
enableNotification(chr.getUuid());
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
|
||||
Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
|
||||
mIsConnected = false;
|
||||
mIsReconnecting = true;
|
||||
mGatt.disconnect();
|
||||
mGatt = connectGatt(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void finishCurrentGattOperation() {
|
||||
GattOperation op = null;
|
||||
synchronized (mOperations) {
|
||||
if (mCurrentOperation != null) {
|
||||
op = mCurrentOperation;
|
||||
mCurrentOperation = null;
|
||||
}
|
||||
}
|
||||
if (op != null) {
|
||||
boolean result = op.finish(); // TODO: Maybe in main thread as well?
|
||||
|
||||
// Our operation failed, let's add it back to the beginning of our queue.
|
||||
if (!result) {
|
||||
mOperations.addFirst(op);
|
||||
}
|
||||
}
|
||||
executeNextGattOperation();
|
||||
}
|
||||
|
||||
private void executeNextGattOperation() {
|
||||
synchronized (mOperations) {
|
||||
if (mCurrentOperation != null)
|
||||
return;
|
||||
|
||||
if (mOperations.isEmpty())
|
||||
return;
|
||||
|
||||
mCurrentOperation = mOperations.removeFirst();
|
||||
}
|
||||
|
||||
Runnable gattOperationRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
synchronized (mOperations) {
|
||||
if (mCurrentOperation == null) {
|
||||
Log.e(TAG, "Current operation null in executor?");
|
||||
return;
|
||||
}
|
||||
|
||||
mCurrentOperation.run();
|
||||
// now wait for the GATT callback and when it comes, finish this operation
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (mCurrentOperation.getDelayMs() == 0) {
|
||||
// Run in main thread
|
||||
mHandler.post(gattOperationRunnable);
|
||||
}
|
||||
else {
|
||||
// If we have a delay on this operation, wait before we post it.
|
||||
mHandler.postDelayed(gattOperationRunnable, mCurrentOperation.getDelayMs());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void queueGattOperation(GattOperation op) {
|
||||
synchronized (mOperations) {
|
||||
mOperations.add(op);
|
||||
}
|
||||
executeNextGattOperation();
|
||||
}
|
||||
|
||||
private void enableNotification(UUID chrUuid) {
|
||||
// Add a 500ms delay to notification write for Amazon Fire TV devices, as otherwise if we do this too quickly after connecting
|
||||
// it will return success and then silently drop the operation on the floor.
|
||||
GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid, 500);
|
||||
queueGattOperation(op);
|
||||
|
||||
// Amazon Fire devices can also silently timeout on writeDescriptor, so
|
||||
// set up a little delayed check that will attempt to write a second time.
|
||||
//
|
||||
// While this only seems to be needed on Amazon Fire TV devices at present, it
|
||||
// doesn't hurt to have a retry on other devices as well.
|
||||
//
|
||||
final HIDDeviceBLESteamController finalThis = this;
|
||||
final UUID finalUuid = chrUuid;
|
||||
mHandler.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!finalThis.mHasEnabledNotifications) {
|
||||
|
||||
if (finalThis.mHasSeenInputUpdate) {
|
||||
// Amazon Five devices may have enabled notifications on the input characteristic and not given us a callback. If we've seen
|
||||
// input reports, though, somewhat by definition notifications are enabled.
|
||||
Log.w(TAG, "WriteDescriptor has never returned, but we've seen input reports. Moving on with controller initialization.");
|
||||
finalThis.mHasEnabledNotifications = true;
|
||||
finalThis.enableValveMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Give one more try.
|
||||
GattOperation retry = HIDDeviceBLESteamController.GattOperation.enableNotification(finalThis.mGatt, finalUuid, 500);
|
||||
finalThis.queueGattOperation(retry);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
void writeCharacteristic(UUID uuid, byte[] value) {
|
||||
GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
|
||||
queueGattOperation(op);
|
||||
}
|
||||
|
||||
void readCharacteristic(UUID uuid) {
|
||||
GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
|
||||
queueGattOperation(op);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
////////////// BluetoothGattCallback overridden methods
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
|
||||
//Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
|
||||
mIsReconnecting = false;
|
||||
if (newState == 2) {
|
||||
mIsConnected = true;
|
||||
// Run directly, without GattOperation
|
||||
if (!isRegistered()) {
|
||||
mHandler.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
mGatt.discoverServices();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (newState == 0) {
|
||||
mIsConnected = false;
|
||||
}
|
||||
|
||||
// Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
|
||||
//Log.v(TAG, "onServicesDiscovered status=" + status);
|
||||
if (status == 0) {
|
||||
if (gatt.getServices().size() == 0) {
|
||||
Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
|
||||
mIsReconnecting = true;
|
||||
mIsConnected = false;
|
||||
gatt.disconnect();
|
||||
mGatt = connectGatt(false);
|
||||
} else {
|
||||
if (getProductId() == TRITON_BLE_PID) {
|
||||
// Android will not properly play well with Data Length Extensions without manually requesting a large MTU,
|
||||
// and Triton controllers require DLE support.
|
||||
//
|
||||
// 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change
|
||||
// this value. It is functionally "please enable data length extensions" on some Android builds.
|
||||
mGatt.requestMtu(517);
|
||||
}
|
||||
|
||||
probeService(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
|
||||
//Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
|
||||
|
||||
if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
|
||||
mManager.HIDDeviceReportResponse(getId(), characteristic.getValue());
|
||||
}
|
||||
|
||||
finishCurrentGattOperation();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
|
||||
//Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
|
||||
|
||||
if (characteristic.getUuid().equals(reportCharacteristic)) {
|
||||
// Only register controller with the native side once it has been fully configured
|
||||
if (!isRegistered()) {
|
||||
Log.v(TAG, "Registering Steam Controller with ID: " + getId());
|
||||
mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId);
|
||||
setRegistered();
|
||||
}
|
||||
}
|
||||
|
||||
finishCurrentGattOperation();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
|
||||
// Enable this for verbose logging of controller input reports
|
||||
//Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
|
||||
|
||||
if (characteristic.getUuid().equals(mInputCharacteristic) && !mFrozen) {
|
||||
mHasSeenInputUpdate = true;
|
||||
mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
|
||||
//Log.v(TAG, "onDescriptorRead status=" + status);
|
||||
}
|
||||
|
||||
private void enableValveMode()
|
||||
{
|
||||
BluetoothGattService valveService = mGatt.getService(steamControllerService);
|
||||
if (valveService == null)
|
||||
return;
|
||||
|
||||
BluetoothGattCharacteristic reportChr = valveService.getCharacteristic(reportCharacteristic);
|
||||
if (reportChr != null) {
|
||||
if (getProductId() == TRITON_BLE_PID) {
|
||||
// For Triton we just mark things registered.
|
||||
Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId());
|
||||
mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId);
|
||||
setRegistered();
|
||||
} else {
|
||||
// For the original controller, we need to manually enter Valve mode.
|
||||
Log.v(TAG, "Writing report characteristic to enter valve mode");
|
||||
reportChr.setValue(enterValveMode);
|
||||
mGatt.writeCharacteristic(reportChr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
|
||||
BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
|
||||
//Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
|
||||
|
||||
if (chr.getUuid().equals(mInputCharacteristic)) {
|
||||
mHasEnabledNotifications = true;
|
||||
enableValveMode();
|
||||
}
|
||||
|
||||
finishCurrentGattOperation();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
|
||||
//Log.v(TAG, "onReliableWriteCompleted status=" + status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
|
||||
//Log.v(TAG, "onReadRemoteRssi status=" + status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
|
||||
//Log.v(TAG, "onMtuChanged status=" + status);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////// Public API
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public int getId() {
|
||||
return mDeviceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getVendorId() {
|
||||
// Valve Corporation
|
||||
final int VALVE_USB_VID = 0x28DE;
|
||||
return VALVE_USB_VID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getProductId() {
|
||||
if (mProductId > 0) {
|
||||
// We've already set a product ID.
|
||||
return mProductId;
|
||||
}
|
||||
|
||||
if (mDevice.getName().startsWith("Steam Ctrl")) {
|
||||
// We're a newer Triton device
|
||||
mProductId = TRITON_BLE_PID;
|
||||
} else {
|
||||
// We're an OG Steam Controller
|
||||
mProductId = D0G_BLE2_PID;
|
||||
}
|
||||
|
||||
return mProductId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSerialNumber() {
|
||||
// This will be read later via feature report by Steam
|
||||
return "12345";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getVersion() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getManufacturerName() {
|
||||
return "Valve Corporation";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProductName() {
|
||||
return "Steam Controller";
|
||||
}
|
||||
|
||||
@Override
|
||||
public UsbDevice getDevice() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean open() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int writeReport(byte[] report, boolean feature) {
|
||||
if (!isRegistered()) {
|
||||
Log.e(TAG, "Attempted writeReport before Steam Controller is registered!");
|
||||
if (mIsConnected) {
|
||||
probeService(this);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (feature) {
|
||||
// We need to skip the first byte, as that doesn't go over the air
|
||||
byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
|
||||
//Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report));
|
||||
writeCharacteristic(reportCharacteristic, actual_report);
|
||||
return report.length;
|
||||
} else {
|
||||
// If we're an original-recipe Steam Controller we just write to the characteristic directly.
|
||||
if (getProductId() == D0G_BLE2_PID) {
|
||||
//Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
|
||||
writeCharacteristic(reportCharacteristic, report);
|
||||
return report.length;
|
||||
}
|
||||
|
||||
// If we're a Triton, we need to find the correct report characteristic.
|
||||
if (report.length > 0) {
|
||||
int reportId = report[0] & 0xFF;
|
||||
BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId);
|
||||
if (targetedReportCharacteristic != null) {
|
||||
byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
|
||||
//Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report));
|
||||
writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report);
|
||||
return report.length;
|
||||
} else {
|
||||
Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readReport(byte[] report, boolean feature) {
|
||||
if (!isRegistered()) {
|
||||
Log.e(TAG, "Attempted readReport before Steam Controller is registered!");
|
||||
if (mIsConnected) {
|
||||
probeService(this);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (feature) {
|
||||
readCharacteristic(reportCharacteristic);
|
||||
return true;
|
||||
} else {
|
||||
// Not implemented
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFrozen(boolean frozen) {
|
||||
mFrozen = frozen;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
close();
|
||||
|
||||
BluetoothGatt g = mGatt;
|
||||
if (g != null) {
|
||||
g.disconnect();
|
||||
g.close();
|
||||
mGatt = null;
|
||||
}
|
||||
mManager = null;
|
||||
mIsRegistered = false;
|
||||
mIsConnected = false;
|
||||
mOperations.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
707
android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
Normal file
707
android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
package org.libsdl.app;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.hardware.usb.*;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
public class HIDDeviceManager {
|
||||
private static final String TAG = "hidapi";
|
||||
private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION";
|
||||
|
||||
private static HIDDeviceManager sManager;
|
||||
private static int sManagerRefCount = 0;
|
||||
|
||||
static public HIDDeviceManager acquire(Context context) {
|
||||
if (sManagerRefCount == 0) {
|
||||
sManager = new HIDDeviceManager(context);
|
||||
}
|
||||
++sManagerRefCount;
|
||||
return sManager;
|
||||
}
|
||||
|
||||
static public void release(HIDDeviceManager manager) {
|
||||
if (manager == sManager) {
|
||||
--sManagerRefCount;
|
||||
if (sManagerRefCount == 0) {
|
||||
sManager.close();
|
||||
sManager = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Context mContext;
|
||||
private HashMap<Integer, HIDDevice> mDevicesById = new HashMap<Integer, HIDDevice>();
|
||||
private HashMap<BluetoothDevice, HIDDeviceBLESteamController> mBluetoothDevices = new HashMap<BluetoothDevice, HIDDeviceBLESteamController>();
|
||||
private int mNextDeviceId = 0;
|
||||
private SharedPreferences mSharedPreferences = null;
|
||||
private boolean mIsChromebook = false;
|
||||
private UsbManager mUsbManager;
|
||||
private Handler mHandler;
|
||||
private BluetoothManager mBluetoothManager;
|
||||
private List<BluetoothDevice> mLastBluetoothDevices;
|
||||
|
||||
private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
|
||||
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
|
||||
handleUsbDeviceAttached(usbDevice);
|
||||
} else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
|
||||
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
|
||||
handleUsbDeviceDetached(usbDevice);
|
||||
} else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
|
||||
UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
|
||||
handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
// Bluetooth device was connected. If it was a Steam Controller, handle it
|
||||
if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
|
||||
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
|
||||
Log.d(TAG, "Bluetooth device connected: " + device);
|
||||
|
||||
if (isSteamController(device)) {
|
||||
connectBluetoothDevice(device);
|
||||
}
|
||||
}
|
||||
|
||||
// Bluetooth device was disconnected, remove from controller manager (if any)
|
||||
if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
|
||||
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
|
||||
Log.d(TAG, "Bluetooth device disconnected: " + device);
|
||||
|
||||
disconnectBluetoothDevice(device);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private HIDDeviceManager(final Context context) {
|
||||
mContext = context;
|
||||
|
||||
HIDDeviceRegisterCallback();
|
||||
|
||||
mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
|
||||
mIsChromebook = SDLActivity.isChromebook();
|
||||
|
||||
// if (shouldClear) {
|
||||
// SharedPreferences.Editor spedit = mSharedPreferences.edit();
|
||||
// spedit.clear();
|
||||
// spedit.apply();
|
||||
// }
|
||||
// else
|
||||
{
|
||||
mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
|
||||
}
|
||||
}
|
||||
|
||||
Context getContext() {
|
||||
return mContext;
|
||||
}
|
||||
|
||||
int getDeviceIDForIdentifier(String identifier) {
|
||||
SharedPreferences.Editor spedit = mSharedPreferences.edit();
|
||||
|
||||
int result = mSharedPreferences.getInt(identifier, 0);
|
||||
if (result == 0) {
|
||||
result = mNextDeviceId++;
|
||||
spedit.putInt("next_device_id", mNextDeviceId);
|
||||
}
|
||||
|
||||
spedit.putInt(identifier, result);
|
||||
spedit.apply();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void initializeUSB() {
|
||||
mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
|
||||
if (mUsbManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
// Logging
|
||||
for (UsbDevice device : mUsbManager.getDeviceList().values()) {
|
||||
Log.i(TAG,"Path: " + device.getDeviceName());
|
||||
Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
|
||||
Log.i(TAG,"Product: " + device.getProductName());
|
||||
Log.i(TAG,"ID: " + device.getDeviceId());
|
||||
Log.i(TAG,"Class: " + device.getDeviceClass());
|
||||
Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
|
||||
Log.i(TAG,"Vendor ID " + device.getVendorId());
|
||||
Log.i(TAG,"Product ID: " + device.getProductId());
|
||||
Log.i(TAG,"Interface count: " + device.getInterfaceCount());
|
||||
Log.i(TAG,"---------------------------------------");
|
||||
|
||||
// Get interface details
|
||||
for (int index = 0; index < device.getInterfaceCount(); index++) {
|
||||
UsbInterface mUsbInterface = device.getInterface(index);
|
||||
Log.i(TAG," ***** *****");
|
||||
Log.i(TAG," Interface index: " + index);
|
||||
Log.i(TAG," Interface ID: " + mUsbInterface.getId());
|
||||
Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass());
|
||||
Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass());
|
||||
Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol());
|
||||
Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount());
|
||||
|
||||
// Get endpoint details
|
||||
for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
|
||||
{
|
||||
UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
|
||||
Log.i(TAG," ++++ ++++ ++++");
|
||||
Log.i(TAG," Endpoint index: " + epi);
|
||||
Log.i(TAG," Attributes: " + mEndpoint.getAttributes());
|
||||
Log.i(TAG," Direction: " + mEndpoint.getDirection());
|
||||
Log.i(TAG," Number: " + mEndpoint.getEndpointNumber());
|
||||
Log.i(TAG," Interval: " + mEndpoint.getInterval());
|
||||
Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize());
|
||||
Log.i(TAG," Type: " + mEndpoint.getType());
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG," No more devices connected.");
|
||||
*/
|
||||
|
||||
// Register for USB broadcasts and permission completions
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
|
||||
filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
|
||||
filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
|
||||
if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */
|
||||
mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED);
|
||||
} else {
|
||||
mContext.registerReceiver(mUsbBroadcast, filter);
|
||||
}
|
||||
|
||||
for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
|
||||
handleUsbDeviceAttached(usbDevice);
|
||||
}
|
||||
}
|
||||
|
||||
UsbManager getUSBManager() {
|
||||
return mUsbManager;
|
||||
}
|
||||
|
||||
private void shutdownUSB() {
|
||||
try {
|
||||
mContext.unregisterReceiver(mUsbBroadcast);
|
||||
} catch (Exception e) {
|
||||
// We may not have registered, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
|
||||
if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
|
||||
return true;
|
||||
}
|
||||
if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
|
||||
final int XB360_IFACE_SUBCLASS = 93;
|
||||
final int XB360_IFACE_PROTOCOL = 1; // Wired
|
||||
final int XB360W_IFACE_PROTOCOL = 129; // Wireless
|
||||
final int[] SUPPORTED_VENDORS = {
|
||||
0x0079, // GPD Win 2
|
||||
0x0351, // CRKD
|
||||
0x044f, // Thrustmaster
|
||||
0x045e, // Microsoft
|
||||
0x046d, // Logitech
|
||||
0x056e, // Elecom
|
||||
0x06a3, // Saitek
|
||||
0x0738, // Mad Catz
|
||||
0x07ff, // Mad Catz
|
||||
0x0e6f, // PDP
|
||||
0x0f0d, // Hori
|
||||
0x1038, // SteelSeries
|
||||
0x10f5, // Turtle Beach
|
||||
0x11c9, // Nacon
|
||||
0x1209, // Generic
|
||||
0x12ab, // Unknown
|
||||
0x1430, // RedOctane
|
||||
0x146b, // BigBen
|
||||
0x1532, // Razer Sabertooth
|
||||
0x15e4, // Numark
|
||||
0x162e, // Joytech
|
||||
0x1689, // Razer Onza
|
||||
0x1949, // Lab126, Inc.
|
||||
0x1bad, // Harmonix
|
||||
0x20d6, // PowerA
|
||||
0x24c6, // PowerA
|
||||
0x2c22, // Qanba
|
||||
0x2dc8, // 8BitDo
|
||||
0x3537, // GameSir
|
||||
0x3651, // CRKD
|
||||
0x37d7, // Flydigi
|
||||
0x3958, // Red Octane Games
|
||||
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 = {
|
||||
0x0351, // CRKD
|
||||
0x03f0, // HP
|
||||
0x044f, // Thrustmaster
|
||||
0x045e, // Microsoft
|
||||
0x0738, // Mad Catz
|
||||
0x0b05, // ASUS
|
||||
0x0e6f, // PDP
|
||||
0x0f0d, // Hori
|
||||
0x10f5, // Turtle Beach
|
||||
0x1209, // Generic
|
||||
0x1532, // Razer Wildcat
|
||||
0x20d6, // PowerA
|
||||
0x24c6, // PowerA
|
||||
0x294b, // Snakebyte
|
||||
0x2dc8, // 8BitDo
|
||||
0x2e24, // Hyperkin
|
||||
0x2e95, // SCUF
|
||||
0x3285, // Nacon
|
||||
0x3537, // GameSir
|
||||
0x3651, // CRKD
|
||||
0x366c, // ByoWave
|
||||
0x3958, // Red Octane Games
|
||||
};
|
||||
|
||||
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<Integer> devices = new ArrayList<Integer>();
|
||||
for (HIDDevice device : mDevicesById.values()) {
|
||||
if (usbDevice.equals(device.getDevice())) {
|
||||
devices.add(device.getId());
|
||||
}
|
||||
}
|
||||
for (int id : devices) {
|
||||
HIDDevice device = mDevicesById.get(id);
|
||||
mDevicesById.remove(id);
|
||||
device.shutdown();
|
||||
HIDDeviceDisconnected(id);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
|
||||
for (HIDDevice device : mDevicesById.values()) {
|
||||
if (usbDevice.equals(device.getDevice())) {
|
||||
boolean opened = false;
|
||||
if (permission_granted) {
|
||||
opened = device.open();
|
||||
}
|
||||
HIDDeviceOpenResult(device.getId(), opened);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void connectHIDDeviceUSB(UsbDevice usbDevice) {
|
||||
synchronized (this) {
|
||||
int interface_mask = 0;
|
||||
for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
|
||||
UsbInterface usbInterface = usbDevice.getInterface(interface_index);
|
||||
if (isHIDDeviceInterface(usbDevice, usbInterface)) {
|
||||
// Check to see if we've already added this interface
|
||||
// This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
|
||||
int interface_id = usbInterface.getId();
|
||||
if ((interface_mask & (1 << interface_id)) != 0) {
|
||||
continue;
|
||||
}
|
||||
interface_mask |= (1 << interface_id);
|
||||
|
||||
HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
|
||||
int id = device.getId();
|
||||
mDevicesById.put(id, device);
|
||||
HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeBluetooth() {
|
||||
Log.d(TAG, "Initializing Bluetooth");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ &&
|
||||
mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ &&
|
||||
mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
|
||||
Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find bonded bluetooth controllers and create SteamControllers for them
|
||||
mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
if (mBluetoothManager == null) {
|
||||
// This device doesn't support Bluetooth.
|
||||
return;
|
||||
}
|
||||
|
||||
BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
|
||||
if (btAdapter == null) {
|
||||
// This device has Bluetooth support in the codebase, but has no available adapters.
|
||||
return;
|
||||
}
|
||||
|
||||
// Get our bonded devices.
|
||||
for (BluetoothDevice device : btAdapter.getBondedDevices()) {
|
||||
|
||||
Log.d(TAG, "Bluetooth device available: " + device);
|
||||
if (isSteamController(device)) {
|
||||
connectBluetoothDevice(device);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// NOTE: These don't work on Chromebooks, to my undying dismay.
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
|
||||
filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
|
||||
if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */
|
||||
mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED);
|
||||
} else {
|
||||
mContext.registerReceiver(mBluetoothBroadcast, filter);
|
||||
}
|
||||
|
||||
if (mIsChromebook) {
|
||||
mHandler = new Handler(Looper.getMainLooper());
|
||||
mLastBluetoothDevices = new ArrayList<BluetoothDevice>();
|
||||
|
||||
// final HIDDeviceManager finalThis = this;
|
||||
// mHandler.postDelayed(new Runnable() {
|
||||
// @Override
|
||||
// public void run() {
|
||||
// finalThis.chromebookConnectionHandler();
|
||||
// }
|
||||
// }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdownBluetooth() {
|
||||
try {
|
||||
mContext.unregisterReceiver(mBluetoothBroadcast);
|
||||
} catch (Exception e) {
|
||||
// We may not have registered, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
// Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
|
||||
// This function provides a sort of dummy version of that, watching for changes in the
|
||||
// connected devices and attempting to add controllers as things change.
|
||||
void chromebookConnectionHandler() {
|
||||
if (!mIsChromebook) {
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayList<BluetoothDevice> disconnected = new ArrayList<BluetoothDevice>();
|
||||
ArrayList<BluetoothDevice> connected = new ArrayList<BluetoothDevice>();
|
||||
|
||||
List<BluetoothDevice> currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
|
||||
|
||||
for (BluetoothDevice bluetoothDevice : currentConnected) {
|
||||
if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
|
||||
connected.add(bluetoothDevice);
|
||||
}
|
||||
}
|
||||
for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
|
||||
if (!currentConnected.contains(bluetoothDevice)) {
|
||||
disconnected.add(bluetoothDevice);
|
||||
}
|
||||
}
|
||||
|
||||
mLastBluetoothDevices = currentConnected;
|
||||
|
||||
for (BluetoothDevice bluetoothDevice : disconnected) {
|
||||
disconnectBluetoothDevice(bluetoothDevice);
|
||||
}
|
||||
for (BluetoothDevice bluetoothDevice : connected) {
|
||||
connectBluetoothDevice(bluetoothDevice);
|
||||
}
|
||||
|
||||
final HIDDeviceManager finalThis = this;
|
||||
mHandler.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
finalThis.chromebookConnectionHandler();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
|
||||
Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
|
||||
synchronized (this) {
|
||||
if (mBluetoothDevices.containsKey(bluetoothDevice)) {
|
||||
Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
|
||||
|
||||
HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
|
||||
device.reconnect();
|
||||
|
||||
return false;
|
||||
}
|
||||
HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
|
||||
int id = device.getId();
|
||||
mBluetoothDevices.put(bluetoothDevice, device);
|
||||
mDevicesById.put(id, device);
|
||||
|
||||
// The Steam Controller will mark itself connected once initialization is complete
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
|
||||
synchronized (this) {
|
||||
HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
|
||||
if (device == null)
|
||||
return;
|
||||
|
||||
int id = device.getId();
|
||||
mBluetoothDevices.remove(bluetoothDevice);
|
||||
mDevicesById.remove(id);
|
||||
device.shutdown();
|
||||
HIDDeviceDisconnected(id);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isSteamController(BluetoothDevice bluetoothDevice) {
|
||||
// Sanity check. If you pass in a null device, by definition it is never a Steam Controller.
|
||||
if (bluetoothDevice == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the device has no local name, we really don't want to try an equality check against it.
|
||||
if (bluetoothDevice.getName() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Steam Controllers will always support Bluetooth Low Energy
|
||||
if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match on the name either the original Steam Controller or the new second-generation one advertise with.
|
||||
return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl");
|
||||
}
|
||||
|
||||
private void close() {
|
||||
shutdownUSB();
|
||||
shutdownBluetooth();
|
||||
synchronized (this) {
|
||||
for (HIDDevice device : mDevicesById.values()) {
|
||||
device.shutdown();
|
||||
}
|
||||
mDevicesById.clear();
|
||||
mBluetoothDevices.clear();
|
||||
HIDDeviceReleaseCallback();
|
||||
}
|
||||
}
|
||||
|
||||
public void setFrozen(boolean frozen) {
|
||||
synchronized (this) {
|
||||
for (HIDDevice device : mDevicesById.values()) {
|
||||
device.setFrozen(frozen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private HIDDevice getDevice(int id) {
|
||||
synchronized (this) {
|
||||
HIDDevice result = mDevicesById.get(id);
|
||||
if (result == null) {
|
||||
Log.v(TAG, "No device for id: " + id);
|
||||
Log.v(TAG, "Available devices: " + mDevicesById.keySet());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
////////// JNI interface functions
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
boolean initialize(boolean usb, boolean bluetooth) {
|
||||
Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
|
||||
|
||||
if (usb) {
|
||||
initializeUSB();
|
||||
}
|
||||
if (bluetooth) {
|
||||
initializeBluetooth();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean openDevice(int deviceID) {
|
||||
Log.v(TAG, "openDevice deviceID=" + deviceID);
|
||||
HIDDevice device = getDevice(deviceID);
|
||||
if (device == null) {
|
||||
HIDDeviceDisconnected(deviceID);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look to see if this is a USB device and we have permission to access it
|
||||
UsbDevice usbDevice = device.getDevice();
|
||||
if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
|
||||
HIDDeviceOpenPending(deviceID);
|
||||
try {
|
||||
final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
|
||||
int flags;
|
||||
if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
|
||||
flags = FLAG_MUTABLE;
|
||||
} else {
|
||||
flags = 0;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION);
|
||||
intent.setPackage(mContext.getPackageName());
|
||||
mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags));
|
||||
} catch (Exception e) {
|
||||
Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
|
||||
HIDDeviceOpenResult(deviceID, false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return device.open();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int writeReport(int deviceID, byte[] report, boolean feature) {
|
||||
try {
|
||||
//Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length);
|
||||
HIDDevice device;
|
||||
device = getDevice(deviceID);
|
||||
if (device == null) {
|
||||
HIDDeviceDisconnected(deviceID);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return device.writeReport(report, feature);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
boolean readReport(int deviceID, byte[] report, boolean feature) {
|
||||
try {
|
||||
//Log.v(TAG, "readReport deviceID=" + deviceID);
|
||||
HIDDevice device;
|
||||
device = getDevice(deviceID);
|
||||
if (device == null) {
|
||||
HIDDeviceDisconnected(deviceID);
|
||||
return false;
|
||||
}
|
||||
|
||||
return device.readReport(report, feature);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void closeDevice(int deviceID) {
|
||||
try {
|
||||
Log.v(TAG, "closeDevice deviceID=" + deviceID);
|
||||
HIDDevice device;
|
||||
device = getDevice(deviceID);
|
||||
if (device == null) {
|
||||
HIDDeviceDisconnected(deviceID);
|
||||
return;
|
||||
}
|
||||
|
||||
device.close();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////// Native methods
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private native void HIDDeviceRegisterCallback();
|
||||
private native void HIDDeviceReleaseCallback();
|
||||
|
||||
native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth, int reportID);
|
||||
native void HIDDeviceOpenPending(int deviceID);
|
||||
native void HIDDeviceOpenResult(int deviceID, boolean opened);
|
||||
native void HIDDeviceDisconnected(int deviceID);
|
||||
|
||||
native void HIDDeviceInputReport(int deviceID, byte[] report);
|
||||
native void HIDDeviceReportResponse(int deviceID, byte[] report);
|
||||
}
|
||||
354
android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
Normal file
354
android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package org.libsdl.app;
|
||||
|
||||
import android.hardware.usb.*;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
class HIDDeviceUSB implements HIDDevice {
|
||||
|
||||
private static final String TAG = "hidapi";
|
||||
|
||||
protected HIDDeviceManager mManager;
|
||||
protected UsbDevice mDevice;
|
||||
protected int mInterfaceIndex;
|
||||
protected int mInterface;
|
||||
protected int mDeviceId;
|
||||
protected UsbDeviceConnection mConnection;
|
||||
protected UsbEndpoint mInputEndpoint;
|
||||
protected UsbEndpoint mOutputEndpoint;
|
||||
protected InputThread mInputThread;
|
||||
protected boolean mRunning;
|
||||
protected boolean mFrozen;
|
||||
protected boolean mClaimed;
|
||||
|
||||
public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
|
||||
mManager = manager;
|
||||
mDevice = usbDevice;
|
||||
mInterfaceIndex = interface_index;
|
||||
mInterface = mDevice.getInterface(mInterfaceIndex).getId();
|
||||
mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
|
||||
mRunning = false;
|
||||
mClaimed = false;
|
||||
}
|
||||
|
||||
String getIdentifier() {
|
||||
return String.format(Locale.ENGLISH, "%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getId() {
|
||||
return mDeviceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getVendorId() {
|
||||
return mDevice.getVendorId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getProductId() {
|
||||
return mDevice.getProductId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSerialNumber() {
|
||||
String result = null;
|
||||
try {
|
||||
result = mDevice.getSerialNumber();
|
||||
}
|
||||
catch (Exception exception) {
|
||||
//Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
|
||||
}
|
||||
if (result == null) {
|
||||
result = "";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getVersion() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getManufacturerName() {
|
||||
String result;
|
||||
result = mDevice.getManufacturerName();
|
||||
if (result == null) {
|
||||
result = String.format("%x", getVendorId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProductName() {
|
||||
String result;
|
||||
result = mDevice.getProductName();
|
||||
if (result == null) {
|
||||
result = String.format("%x", getProductId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UsbDevice getDevice() {
|
||||
return mDevice;
|
||||
}
|
||||
|
||||
String getDeviceName() {
|
||||
return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean open() {
|
||||
mConnection = mManager.getUSBManager().openDevice(mDevice);
|
||||
if (mConnection == null) {
|
||||
Log.w(TAG, "Unable to open USB device " + getDeviceName());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Force claim our interface
|
||||
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
|
||||
if (!mConnection.claimInterface(iface, true)) {
|
||||
Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
mClaimed = true;
|
||||
|
||||
// Find the endpoints
|
||||
for (int j = 0; j < iface.getEndpointCount(); j++) {
|
||||
UsbEndpoint endpt = iface.getEndpoint(j);
|
||||
switch (endpt.getDirection()) {
|
||||
case UsbConstants.USB_DIR_IN:
|
||||
if (mInputEndpoint == null) {
|
||||
mInputEndpoint = endpt;
|
||||
}
|
||||
break;
|
||||
case UsbConstants.USB_DIR_OUT:
|
||||
if (mOutputEndpoint == null) {
|
||||
mOutputEndpoint = endpt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the required endpoints were present. The original Steam Controller and the wireless dongle for it do NOT
|
||||
// actually have -- or require -- output endpoints, so we need to accept only an input one for them or else we'll fall
|
||||
// back to the Android system gamepad functionality (and lose our paddles et al).
|
||||
if (mInputEndpoint == null) {
|
||||
Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
|
||||
mConnection.releaseInterface(iface);
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start listening for input
|
||||
mRunning = true;
|
||||
mInputThread = new InputThread();
|
||||
mInputThread.start();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int writeReport(byte[] report, boolean feature) {
|
||||
if (mConnection == null) {
|
||||
Log.w(TAG, "writeReport() called with no device connection");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!mClaimed) {
|
||||
Log.w(TAG, "writeReport() called but some other process currently owns the USB device");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (feature) {
|
||||
int res = -1;
|
||||
int offset = 0;
|
||||
int length = report.length;
|
||||
boolean skipped_report_id = false;
|
||||
byte report_number = report[0];
|
||||
|
||||
if (report_number == 0x0) {
|
||||
++offset;
|
||||
--length;
|
||||
skipped_report_id = true;
|
||||
}
|
||||
|
||||
res = mConnection.controlTransfer(
|
||||
UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
|
||||
0x09/*HID set_report*/,
|
||||
(3/*HID feature*/ << 8) | report_number,
|
||||
mInterface,
|
||||
report, offset, length,
|
||||
1000/*timeout millis*/);
|
||||
|
||||
if (res < 0) {
|
||||
Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName());
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (skipped_report_id) {
|
||||
++length;
|
||||
}
|
||||
return length;
|
||||
} else {
|
||||
if (mOutputEndpoint == null)
|
||||
{
|
||||
Log.e(TAG, "Tried to write an output report to an interface with no output endpoint!");
|
||||
return -1;
|
||||
}
|
||||
int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
|
||||
if (res != report.length) {
|
||||
Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName());
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readReport(byte[] report, boolean feature) {
|
||||
int res = -1;
|
||||
int offset = 0;
|
||||
int length = report.length;
|
||||
boolean skipped_report_id = false;
|
||||
byte report_number = report[0];
|
||||
|
||||
if (mConnection == null) {
|
||||
Log.w(TAG, "readReport() called with no device connection");
|
||||
return false;
|
||||
}
|
||||
if (!mClaimed) {
|
||||
if (feature) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (report_number == 0x0) {
|
||||
/* Offset the return buffer by 1, so that the report ID
|
||||
will remain in byte 0. */
|
||||
++offset;
|
||||
--length;
|
||||
skipped_report_id = true;
|
||||
}
|
||||
|
||||
res = mConnection.controlTransfer(
|
||||
UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
|
||||
0x01/*HID get_report*/,
|
||||
((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number,
|
||||
mInterface,
|
||||
report, offset, length,
|
||||
1000/*timeout millis*/);
|
||||
|
||||
if (res < 0) {
|
||||
Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (skipped_report_id) {
|
||||
++res;
|
||||
++length;
|
||||
}
|
||||
|
||||
byte[] data;
|
||||
if (res == length) {
|
||||
data = report;
|
||||
} else {
|
||||
data = Arrays.copyOfRange(report, 0, res);
|
||||
}
|
||||
mManager.HIDDeviceReportResponse(mDeviceId, data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
mRunning = false;
|
||||
if (mInputThread != null) {
|
||||
while (mInputThread.isAlive()) {
|
||||
mInputThread.interrupt();
|
||||
try {
|
||||
mInputThread.join();
|
||||
} catch (InterruptedException e) {
|
||||
// Keep trying until we're done
|
||||
}
|
||||
}
|
||||
mInputThread = null;
|
||||
}
|
||||
if (mConnection != null) {
|
||||
if (mClaimed) {
|
||||
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
|
||||
mConnection.releaseInterface(iface);
|
||||
}
|
||||
mConnection.close();
|
||||
mConnection = null;
|
||||
mClaimed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
close();
|
||||
mManager = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFrozen(boolean frozen) {
|
||||
mFrozen = frozen;
|
||||
|
||||
/* If we have a valid device connection and the claim state doesn't match what we want, try to correct that. */
|
||||
if (mConnection != null && mClaimed == mFrozen) {
|
||||
UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
|
||||
if (frozen) {
|
||||
mClaimed = !mConnection.releaseInterface(iface);
|
||||
if (mClaimed) {
|
||||
Log.e(TAG, "Tried to release claim on USB device, but failed!");
|
||||
}
|
||||
} else {
|
||||
mClaimed = mConnection.claimInterface(iface, true);
|
||||
if (!mClaimed) {
|
||||
Log.e(TAG, "Tried to regain claim on USB device, but failed!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected class InputThread extends Thread {
|
||||
@Override
|
||||
public void run() {
|
||||
int packetSize = mInputEndpoint.getMaxPacketSize();
|
||||
byte[] packet = new byte[packetSize];
|
||||
while (mRunning) {
|
||||
int r;
|
||||
try
|
||||
{
|
||||
r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
|
||||
break;
|
||||
}
|
||||
if (r < 0) {
|
||||
// Could be a timeout or an I/O error
|
||||
}
|
||||
if (r > 0) {
|
||||
byte[] data;
|
||||
if (r == packetSize) {
|
||||
data = packet;
|
||||
} else {
|
||||
data = Arrays.copyOfRange(packet, 0, r);
|
||||
}
|
||||
|
||||
if (!mFrozen) {
|
||||
mManager.HIDDeviceInputReport(mDeviceId, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
android/app/src/main/java/org/libsdl/app/SDL.java
Normal file
90
android/app/src/main/java/org/libsdl/app/SDL.java
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package org.libsdl.app;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
|
||||
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
|
||||
static public void setupJNI() {
|
||||
SDLActivity.nativeSetupJNI();
|
||||
SDLAudioManager.nativeSetupJNI();
|
||||
SDLControllerManager.nativeSetupJNI();
|
||||
}
|
||||
|
||||
// This function should be called each time the activity is started
|
||||
static public void initialize() {
|
||||
setContext(null);
|
||||
|
||||
SDLActivity.initialize();
|
||||
SDLAudioManager.initialize();
|
||||
SDLControllerManager.initialize();
|
||||
}
|
||||
|
||||
// This function stores the current activity (SDL or not)
|
||||
static public void setContext(Activity context) {
|
||||
SDLAudioManager.setContext(context);
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
static public Activity getContext() {
|
||||
return mContext;
|
||||
}
|
||||
|
||||
static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
|
||||
loadLibrary(libraryName, mContext);
|
||||
}
|
||||
|
||||
static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
|
||||
|
||||
if (libraryName == null) {
|
||||
throw new NullPointerException("No library name provided.");
|
||||
}
|
||||
|
||||
try {
|
||||
// Let's see if we have ReLinker available in the project. This is necessary for
|
||||
// some projects that have huge numbers of local libraries bundled, and thus may
|
||||
// trip a bug in Android's native library loader which ReLinker works around. (If
|
||||
// loadLibrary works properly, ReLinker will simply use the normal Android method
|
||||
// internally.)
|
||||
//
|
||||
// To use ReLinker, just add it as a dependency. For more information, see
|
||||
// https://github.com/KeepSafe/ReLinker for ReLinker's repository.
|
||||
//
|
||||
Class<?> relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
|
||||
Class<?> relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
|
||||
Class<?> contextClass = context.getClassLoader().loadClass("android.content.Context");
|
||||
Class<?> stringClass = context.getClassLoader().loadClass("java.lang.String");
|
||||
|
||||
// Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if
|
||||
// they've changed during updates.
|
||||
Method forceMethod = relinkClass.getDeclaredMethod("force");
|
||||
Object relinkInstance = forceMethod.invoke(null);
|
||||
Class<?> relinkInstanceClass = relinkInstance.getClass();
|
||||
|
||||
// Actually load the library!
|
||||
Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
|
||||
loadMethod.invoke(relinkInstance, context, libraryName, null, null);
|
||||
}
|
||||
catch (final Throwable e) {
|
||||
// Fall back
|
||||
try {
|
||||
System.loadLibrary(libraryName);
|
||||
}
|
||||
catch (final UnsatisfiedLinkError ule) {
|
||||
throw ule;
|
||||
}
|
||||
catch (final SecurityException se) {
|
||||
throw se;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static Activity mContext;
|
||||
}
|
||||
2247
android/app/src/main/java/org/libsdl/app/SDLActivity.java
Normal file
2247
android/app/src/main/java/org/libsdl/app/SDLActivity.java
Normal file
File diff suppressed because it is too large
Load diff
126
android/app/src/main/java/org/libsdl/app/SDLAudioManager.java
Normal file
126
android/app/src/main/java/org/libsdl/app/SDLAudioManager.java
Normal file
|
|
@ -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;
|
||||
|
||||
class SDLAudioManager {
|
||||
protected static final String TAG = "SDLAudio";
|
||||
|
||||
protected static Context mContext;
|
||||
|
||||
private static AudioDeviceCallback mAudioDeviceCallback;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static void setContext(Context context) {
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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. */
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
static native void nativeSetupJNI();
|
||||
|
||||
static native void nativeRemoveAudioDevice(boolean recording, int deviceId);
|
||||
|
||||
static native void nativeAddAudioDevice(boolean recording, String name, int deviceId);
|
||||
|
||||
}
|
||||
1010
android/app/src/main/java/org/libsdl/app/SDLControllerManager.java
Normal file
1010
android/app/src/main/java/org/libsdl/app/SDLControllerManager.java
Normal file
File diff suppressed because it is too large
Load diff
66
android/app/src/main/java/org/libsdl/app/SDLDummyEdit.java
Normal file
66
android/app/src/main/java/org/libsdl/app/SDLDummyEdit.java
Normal file
|
|
@ -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;
|
||||
|
||||
SDLDummyEdit(Context context) {
|
||||
super(context);
|
||||
setFocusableInTouchMode(true);
|
||||
setFocusable(true);
|
||||
setOnKeyListener(this);
|
||||
}
|
||||
|
||||
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 SDLActivity.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 (SDLActivity.mTextEdit != null && SDLActivity.mTextEdit.getVisibility() == View.VISIBLE) {
|
||||
SDLActivity.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;
|
||||
}
|
||||
}
|
||||
|
||||
136
android/app/src/main/java/org/libsdl/app/SDLInputConnection.java
Normal file
136
android/app/src/main/java/org/libsdl/app/SDLInputConnection.java
Normal file
|
|
@ -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;
|
||||
|
||||
class SDLInputConnection extends BaseInputConnection
|
||||
{
|
||||
protected EditText mEditText;
|
||||
protected String mCommittedText = "";
|
||||
|
||||
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 (SDLActivity.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) {
|
||||
// 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();
|
||||
if (!SDLActivity.dispatchingKeyEvent()) {
|
||||
for (offset = 0; offset < pendingText.length(); ) {
|
||||
int codePoint = pendingText.codePointAt(offset);
|
||||
if (codePoint == '\n') {
|
||||
if (SDLActivity.onNativeSoftReturnKey()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* Higher code points don't generate simulated scancodes */
|
||||
if (codePoint > 0 && 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);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package org.libsdl.app;
|
||||
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.util.Log;
|
||||
|
||||
// This class coordinates synchronized access to sensor manager registration
|
||||
//
|
||||
// This prevents a java.util.ConcurrentModificationException exception on
|
||||
// Android 16, specifically on the Samsung Tab S9 Ultra.
|
||||
|
||||
class SDLSensorManager
|
||||
{
|
||||
static private SDLSensorManager mManager = new SDLSensorManager();
|
||||
|
||||
static final int RETRY_COUNT = 3;
|
||||
|
||||
public static void registerListener(SensorManager manager, SensorEventListener listener, Sensor sensor, int samplingPeriodUs) {
|
||||
mManager.RegisterListener(manager, listener, sensor, samplingPeriodUs);
|
||||
}
|
||||
|
||||
public static void unregisterListener(SensorManager manager, SensorEventListener listener, Sensor sensor) {
|
||||
mManager.UnregisterListener(manager, listener, sensor);
|
||||
}
|
||||
|
||||
private synchronized void RegisterListener(SensorManager manager, SensorEventListener listener, Sensor sensor, int samplingPeriodUs) {
|
||||
int retries = 0;
|
||||
boolean complete = false;
|
||||
while (!complete) {
|
||||
try {
|
||||
manager.registerListener(listener, sensor, samplingPeriodUs, null);
|
||||
complete = true;
|
||||
} catch (java.util.ConcurrentModificationException e) {
|
||||
++retries;
|
||||
if (retries <= RETRY_COUNT) {
|
||||
// Sleep a bit and try again
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch (Exception e2) {
|
||||
}
|
||||
} else {
|
||||
Log.v("SDL", "Multiple ConcurrentModificationException caught while registering sensor listener, canceling operation");
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void UnregisterListener(SensorManager manager, SensorEventListener listener, Sensor sensor) {
|
||||
int retries = 0;
|
||||
boolean complete = false;
|
||||
while (!complete) {
|
||||
try {
|
||||
manager.unregisterListener(listener, sensor);
|
||||
complete = true;
|
||||
} catch (java.util.ConcurrentModificationException e) {
|
||||
++retries;
|
||||
if (retries <= RETRY_COUNT) {
|
||||
// Sleep a bit and try again
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch (Exception e2) {
|
||||
}
|
||||
} else {
|
||||
Log.v("SDL", "Multiple ConcurrentModificationException caught while unregistering sensor listener, canceling operation");
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
426
android/app/src/main/java/org/libsdl/app/SDLSurface.java
Normal file
426
android/app/src/main/java/org/libsdl/app/SDLSurface.java
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
package org.libsdl.app;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.graphics.Insets;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.os.Build;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.view.Display;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.PointerIcon;
|
||||
import android.view.Surface;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import android.view.ScaleGestureDetector;
|
||||
|
||||
/**
|
||||
SDLSurface. This is what we draw on, so we need to know when it's created
|
||||
in order to do anything useful.
|
||||
|
||||
Because of this, that's where we set up the SDL thread
|
||||
*/
|
||||
public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
|
||||
View.OnApplyWindowInsetsListener, View.OnKeyListener, View.OnTouchListener,
|
||||
SensorEventListener, ScaleGestureDetector.OnScaleGestureListener {
|
||||
|
||||
// Sensors
|
||||
protected SensorManager mSensorManager;
|
||||
protected Display mDisplay;
|
||||
|
||||
// Keep track of the surface size to normalize touch events
|
||||
protected float mWidth, mHeight;
|
||||
|
||||
// Is SurfaceView ready for rendering
|
||||
protected boolean mIsSurfaceReady;
|
||||
|
||||
// Is on-screen keyboard visible
|
||||
protected boolean mKeyboardVisible;
|
||||
|
||||
// Pinch events
|
||||
private final ScaleGestureDetector scaleGestureDetector;
|
||||
|
||||
// Startup
|
||||
protected SDLSurface(Context context) {
|
||||
super(context);
|
||||
getHolder().addCallback(this);
|
||||
|
||||
scaleGestureDetector = new ScaleGestureDetector(context, this);
|
||||
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
requestFocus();
|
||||
setOnApplyWindowInsetsListener(this);
|
||||
setOnKeyListener(this);
|
||||
setOnTouchListener(this);
|
||||
|
||||
mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
|
||||
mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
|
||||
|
||||
setOnGenericMotionListener(SDLActivity.getMotionListener());
|
||||
|
||||
// Some arbitrary defaults to avoid a potential division by zero
|
||||
mWidth = 1.0f;
|
||||
mHeight = 1.0f;
|
||||
|
||||
mIsSurfaceReady = false;
|
||||
}
|
||||
|
||||
protected void handlePause() {
|
||||
enableSensor(Sensor.TYPE_ACCELEROMETER, false);
|
||||
}
|
||||
|
||||
protected void handleResume() {
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
requestFocus();
|
||||
setOnApplyWindowInsetsListener(this);
|
||||
setOnKeyListener(this);
|
||||
setOnTouchListener(this);
|
||||
enableSensor(Sensor.TYPE_ACCELEROMETER, true);
|
||||
}
|
||||
|
||||
protected Surface getNativeSurface() {
|
||||
return getHolder().getSurface();
|
||||
}
|
||||
|
||||
// Called when we have a valid drawing surface
|
||||
@Override
|
||||
public void surfaceCreated(SurfaceHolder holder) {
|
||||
Log.v("SDL", "surfaceCreated()");
|
||||
SDLActivity.onNativeSurfaceCreated();
|
||||
}
|
||||
|
||||
// Called when we lose the surface
|
||||
@Override
|
||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||
Log.v("SDL", "surfaceDestroyed()");
|
||||
|
||||
// Transition to pause, if needed
|
||||
SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
|
||||
SDLActivity.handleNativeState();
|
||||
|
||||
mIsSurfaceReady = false;
|
||||
SDLActivity.onNativeSurfaceDestroyed();
|
||||
}
|
||||
|
||||
// Called when the surface is resized
|
||||
@Override
|
||||
public void surfaceChanged(SurfaceHolder holder,
|
||||
int format, int width, int height) {
|
||||
Log.v("SDL", "surfaceChanged()");
|
||||
|
||||
if (SDLActivity.mSingleton == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mWidth = width;
|
||||
mHeight = height;
|
||||
int nDeviceWidth = width;
|
||||
int nDeviceHeight = height;
|
||||
float density = 1.0f;
|
||||
try
|
||||
{
|
||||
DisplayMetrics realMetrics = new DisplayMetrics();
|
||||
mDisplay.getRealMetrics( realMetrics );
|
||||
nDeviceWidth = realMetrics.widthPixels;
|
||||
nDeviceHeight = realMetrics.heightPixels;
|
||||
// Use densityDpi instead of density to more closely match what the UI scale is
|
||||
density = (float)realMetrics.densityDpi / 160.0f;
|
||||
} catch(Exception ignored) {
|
||||
}
|
||||
|
||||
synchronized(SDLActivity.getContext()) {
|
||||
// In case we're waiting on a size change after going fullscreen, send a notification.
|
||||
SDLActivity.getContext().notifyAll();
|
||||
}
|
||||
|
||||
Log.v("SDL", "Window size: " + width + "x" + height);
|
||||
Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
|
||||
SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, density, mDisplay.getRefreshRate());
|
||||
SDLActivity.onNativeResize();
|
||||
|
||||
// Prevent a screen distortion glitch,
|
||||
// for instance when the device is in Landscape and a Portrait App is resumed.
|
||||
boolean skip = false;
|
||||
int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
|
||||
|
||||
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
|
||||
if (mWidth > mHeight) {
|
||||
skip = true;
|
||||
}
|
||||
} else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
|
||||
if (mWidth < mHeight) {
|
||||
skip = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Special Patch for Square Resolution: Black Berry Passport
|
||||
if (skip) {
|
||||
double min = Math.min(mWidth, mHeight);
|
||||
double max = Math.max(mWidth, mHeight);
|
||||
|
||||
if (max / min < 1.20) {
|
||||
Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
|
||||
skip = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't skip if we might be multi-window or have popup dialogs
|
||||
if (skip) {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
skip = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
Log.v("SDL", "Skip .. Surface is not ready.");
|
||||
mIsSurfaceReady = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
|
||||
SDLActivity.onNativeSurfaceChanged();
|
||||
|
||||
/* Surface is ready */
|
||||
mIsSurfaceReady = true;
|
||||
|
||||
SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
|
||||
SDLActivity.handleNativeState();
|
||||
}
|
||||
|
||||
// Window inset
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
|
||||
if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) {
|
||||
Insets combined = insets.getInsets(WindowInsets.Type.systemBars() |
|
||||
WindowInsets.Type.systemGestures() |
|
||||
WindowInsets.Type.mandatorySystemGestures() |
|
||||
WindowInsets.Type.tappableElement() |
|
||||
WindowInsets.Type.displayCutout());
|
||||
|
||||
SDLActivity.onNativeInsetsChanged(combined.left, combined.right, combined.top, combined.bottom);
|
||||
|
||||
if (insets.isVisible(WindowInsets.Type.ime())) {
|
||||
if (!mKeyboardVisible) {
|
||||
mKeyboardVisible = true;
|
||||
SDLActivity.onNativeScreenKeyboardShown();
|
||||
}
|
||||
} else {
|
||||
if (mKeyboardVisible) {
|
||||
mKeyboardVisible = false;
|
||||
SDLActivity.onNativeScreenKeyboardHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass these to any child views in case they need them
|
||||
return insets;
|
||||
}
|
||||
|
||||
// Key events
|
||||
@Override
|
||||
public boolean onKey(View v, int keyCode, KeyEvent event) {
|
||||
return SDLActivity.handleKeyEvent(v, keyCode, event, null);
|
||||
}
|
||||
|
||||
private float getNormalizedX(float x)
|
||||
{
|
||||
if (mWidth <= 1) {
|
||||
return 0.5f;
|
||||
} else {
|
||||
return (x / (mWidth - 1));
|
||||
}
|
||||
}
|
||||
|
||||
private float getNormalizedY(float y)
|
||||
{
|
||||
if (mHeight <= 1) {
|
||||
return 0.5f;
|
||||
} else {
|
||||
return (y / (mHeight - 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Touch events
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
/* Ref: http://developer.android.com/training/gestures/multi.html */
|
||||
int touchDevId = event.getDeviceId();
|
||||
final int pointerCount = event.getPointerCount();
|
||||
int action = event.getActionMasked();
|
||||
int pointerId;
|
||||
int i = 0;
|
||||
float x,y,p;
|
||||
|
||||
if (action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN)
|
||||
i = event.getActionIndex();
|
||||
|
||||
do {
|
||||
int toolType = event.getToolType(i);
|
||||
|
||||
if (toolType == MotionEvent.TOOL_TYPE_MOUSE) {
|
||||
int buttonState = event.getButtonState();
|
||||
boolean relative = false;
|
||||
|
||||
// We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
|
||||
// if we are. We'll leverage our existing mouse motion listener
|
||||
SDLGenericMotionListener_API14 motionListener = SDLActivity.getMotionListener();
|
||||
x = motionListener.getEventX(event, i);
|
||||
y = motionListener.getEventY(event, i);
|
||||
relative = motionListener.inRelativeMode();
|
||||
|
||||
SDLActivity.onNativeMouse(buttonState, action, x, y, relative);
|
||||
} else if (toolType == MotionEvent.TOOL_TYPE_STYLUS || toolType == MotionEvent.TOOL_TYPE_ERASER) {
|
||||
pointerId = event.getPointerId(i);
|
||||
x = event.getX(i);
|
||||
y = event.getY(i);
|
||||
p = event.getPressure(i);
|
||||
if (p > 1.0f) {
|
||||
// may be larger than 1.0f on some devices
|
||||
// see the documentation of getPressure(i)
|
||||
p = 1.0f;
|
||||
}
|
||||
|
||||
// BUTTON_STYLUS_PRIMARY is 2^5, so shift by 4, and apply SDL_PEN_INPUT_DOWN/SDL_PEN_INPUT_ERASER_TIP
|
||||
int buttonState = (event.getButtonState() >> 4) | (1 << (toolType == MotionEvent.TOOL_TYPE_STYLUS ? 0 : 30));
|
||||
if ((event.getButtonState() & MotionEvent.BUTTON_TERTIARY) != 0) {
|
||||
buttonState |= 0x08;
|
||||
}
|
||||
|
||||
SDLActivity.onNativePen(pointerId, SDLActivity.getMotionListener().getPenDeviceType(event.getDevice()), buttonState, action, x, y, p);
|
||||
} else { // MotionEvent.TOOL_TYPE_FINGER or MotionEvent.TOOL_TYPE_UNKNOWN
|
||||
pointerId = event.getPointerId(i);
|
||||
x = getNormalizedX(event.getX(i));
|
||||
y = getNormalizedY(event.getY(i));
|
||||
p = event.getPressure(i);
|
||||
if (p > 1.0f) {
|
||||
// may be larger than 1.0f on some devices
|
||||
// see the documentation of getPressure(i)
|
||||
p = 1.0f;
|
||||
}
|
||||
|
||||
SDLActivity.onNativeTouch(touchDevId, pointerId, action, x, y, p);
|
||||
}
|
||||
|
||||
// Non-primary up/down
|
||||
if (action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN)
|
||||
break;
|
||||
} while (++i < pointerCount);
|
||||
|
||||
scaleGestureDetector.onTouchEvent(event);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sensor events
|
||||
protected void enableSensor(int sensortype, boolean enabled) {
|
||||
// TODO: This uses getDefaultSensor - what if we have >1 accels?
|
||||
if (enabled) {
|
||||
SDLSensorManager.registerListener(mSensorManager, this,
|
||||
mSensorManager.getDefaultSensor(sensortype),
|
||||
SensorManager.SENSOR_DELAY_GAME);
|
||||
} else {
|
||||
SDLSensorManager.unregisterListener(mSensorManager, this,
|
||||
mSensorManager.getDefaultSensor(sensortype));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
|
||||
|
||||
// Since we may have an orientation set, we won't receive onConfigurationChanged events.
|
||||
// We thus should check here.
|
||||
int newRotation;
|
||||
|
||||
float x, y;
|
||||
switch (mDisplay.getRotation()) {
|
||||
case Surface.ROTATION_0:
|
||||
default:
|
||||
x = event.values[0];
|
||||
y = event.values[1];
|
||||
newRotation = 0;
|
||||
break;
|
||||
case Surface.ROTATION_90:
|
||||
x = -event.values[1];
|
||||
y = event.values[0];
|
||||
newRotation = 90;
|
||||
break;
|
||||
case Surface.ROTATION_180:
|
||||
x = -event.values[0];
|
||||
y = -event.values[1];
|
||||
newRotation = 180;
|
||||
break;
|
||||
case Surface.ROTATION_270:
|
||||
x = event.values[1];
|
||||
y = -event.values[0];
|
||||
newRotation = 270;
|
||||
break;
|
||||
}
|
||||
|
||||
if (newRotation != SDLActivity.mCurrentRotation) {
|
||||
SDLActivity.mCurrentRotation = newRotation;
|
||||
SDLActivity.onNativeRotationChanged(newRotation);
|
||||
}
|
||||
|
||||
SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
|
||||
y / SensorManager.GRAVITY_EARTH,
|
||||
event.values[2] / SensorManager.GRAVITY_EARTH);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent android internal NullPointerException (https://github.com/libsdl-org/SDL/issues/13306)
|
||||
@Override
|
||||
public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
|
||||
try {
|
||||
return super.onResolvePointerIcon(event, pointerIndex);
|
||||
} catch (NullPointerException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Captured pointer events for API 26.
|
||||
@Override
|
||||
public boolean onCapturedPointerEvent(MotionEvent event)
|
||||
{
|
||||
return SDLActivity.getMotionListener().onGenericMotion(this, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScale(ScaleGestureDetector detector) {
|
||||
float scale = detector.getScaleFactor();
|
||||
SDLActivity.onNativePinchUpdate(scale);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScaleBegin(ScaleGestureDetector detector) {
|
||||
SDLActivity.onNativePinchStart();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScaleEnd(ScaleGestureDetector detector) {
|
||||
SDLActivity.onNativePinchEnd();
|
||||
}
|
||||
|
||||
}
|
||||
145
android/build.sh
Executable file
145
android/build.sh
Executable file
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env bash
|
||||
# Сборка debug-APK клиента gmod-rs под Android (arm64-v8a).
|
||||
#
|
||||
# Без Gradle: SDK/NDK + cargo + aapt2/d8/apksigner. Так проще в окружении без
|
||||
# скачивания Android Gradle Plugin, а результатом всё равно является обычный
|
||||
# подписанный APK.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ANDROID_DIR="$ROOT/android"
|
||||
BUILD="$ANDROID_DIR/build"
|
||||
OUT_DIR="$ROOT/target/android"
|
||||
API=31
|
||||
ABI="${ANDROID_ABI:-arm64-v8a}"
|
||||
case "$ABI" in
|
||||
arm64-v8a) TARGET=aarch64-linux-android; CLANG_PREFIX=aarch64-linux-android ;;
|
||||
x86_64) TARGET=x86_64-linux-android; CLANG_PREFIX=x86_64-linux-android ;;
|
||||
armeabi-v7a) TARGET=armv7-linux-androideabi; CLANG_PREFIX=armv7a-linux-androideabi ;;
|
||||
x86) TARGET=i686-linux-android; CLANG_PREFIX=i686-linux-android ;;
|
||||
*) echo "неизвестный ABI: $ABI" >&2; exit 1 ;;
|
||||
esac
|
||||
if [[ "$ABI" == "arm64-v8a" ]]; then
|
||||
OUT="$OUT_DIR/gmod-rs-debug.apk"
|
||||
else
|
||||
OUT="$OUT_DIR/gmod-rs-debug-$ABI.apk"
|
||||
fi
|
||||
|
||||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
if [[ -z "$SDK" || ! -d "$SDK" ]]; then
|
||||
for candidate in "$HOME/Android/Sdk" /opt/android-sdk; do
|
||||
if [[ -d "$candidate" ]]; then SDK="$candidate"; break; fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$SDK" || ! -d "$SDK" ]]; then
|
||||
echo "не найден Android SDK (задайте ANDROID_HOME)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK:-}}"
|
||||
if [[ -z "$NDK" || ! -d "$NDK" ]]; then
|
||||
NDK="$(find "$SDK/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1 || true)"
|
||||
fi
|
||||
if [[ -z "$NDK" || ! -d "$NDK" ]]; then
|
||||
NDK=/opt/android-ndk
|
||||
fi
|
||||
if [[ ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
|
||||
echo "не найден Android NDK (задайте ANDROID_NDK_HOME)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_TOOLS="$(find "$SDK/build-tools" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)"
|
||||
PLATFORM_DIR="$(find "$SDK/platforms" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)"
|
||||
ANDROID_JAR="$PLATFORM_DIR/android.jar"
|
||||
if [[ ! -f "$ANDROID_JAR" ]]; then
|
||||
echo "не найден android.jar в $PLATFORM_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
CC_BIN="$TOOLCHAIN/${CLANG_PREFIX}${API}-clang"
|
||||
if [[ ! -x "$CC_BIN" ]]; then
|
||||
CC_BIN="$TOOLCHAIN/${CLANG_PREFIX}-clang"
|
||||
fi
|
||||
if [[ ! -x "$CC_BIN" ]]; then
|
||||
echo "не найден clang NDK для $TARGET в $TOOLCHAIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- нативная часть -------------------------------------------------------
|
||||
export ANDROID_NDK="$NDK"
|
||||
export CMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake"
|
||||
export ANDROID_PLATFORM="android-$API"
|
||||
TARGET_U="${TARGET//-/_}"
|
||||
export "CC_${TARGET_U}=$CC_BIN"
|
||||
export "AR_${TARGET_U}=$TOOLCHAIN/llvm-ar"
|
||||
export "RANLIB_${TARGET_U}=$TOOLCHAIN/llvm-ranlib"
|
||||
LINKER_VAR="CARGO_TARGET_$(echo "$TARGET_U" | tr '[:lower:]' '[:upper:]')_LINKER"
|
||||
export "$LINKER_VAR=$CC_BIN"
|
||||
|
||||
echo "== cargo build ($TARGET / $ABI, release)"
|
||||
(cd "$ROOT" && cargo build --release -p client --lib --target "$TARGET")
|
||||
|
||||
LIB_DIR="$ROOT/target/$TARGET/release"
|
||||
SDL_SO="$LIB_DIR/libSDL3.so"
|
||||
CLIENT_SO="$LIB_DIR/libclient.so"
|
||||
if [[ ! -f "$SDL_SO" ]]; then
|
||||
# sdl3-sys кладёт собранную SDL3 в каталог сборки, если не смог
|
||||
# скопировать её наверх.
|
||||
SDL_SO="$(find "$ROOT/target/$TARGET/release/build" -path '*/out/lib/libSDL3.so' | head -1)"
|
||||
fi
|
||||
if [[ ! -f "$SDL_SO" || ! -f "$CLIENT_SO" ]]; then
|
||||
echo "не найдены собранные libSDL3.so/libclient.so" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Java + пакет ---------------------------------------------------------
|
||||
rm -rf "$BUILD"
|
||||
mkdir -p "$BUILD/classes" "$BUILD/dex" "$BUILD/lib/$ABI" "$OUT_DIR"
|
||||
|
||||
echo "== javac"
|
||||
find "$ANDROID_DIR/app/src/main/java" -name '*.java' > "$BUILD/sources.txt"
|
||||
javac --release 11 -Xlint:-options -cp "$ANDROID_JAR" -d "$BUILD/classes" @"$BUILD/sources.txt"
|
||||
|
||||
echo "== d8"
|
||||
find "$BUILD/classes" -name '*.class' > "$BUILD/classes.txt"
|
||||
"$BUILD_TOOLS/d8" --release --lib "$ANDROID_JAR" --min-api "$API" \
|
||||
--output "$BUILD/dex" @"$BUILD/classes.txt"
|
||||
|
||||
echo "== aapt2 link"
|
||||
"$BUILD_TOOLS/aapt2" link -o "$BUILD/unsigned.apk" \
|
||||
--manifest "$ANDROID_DIR/AndroidManifest.xml" \
|
||||
-I "$ANDROID_JAR"
|
||||
|
||||
cp "$SDL_SO" "$BUILD/lib/$ABI/libSDL3.so"
|
||||
# SDLActivity грузит «main»: имя файла обязано быть libmain.so, а не
|
||||
# libclient.so (внутри — наш cdylib с экспортом SDL_main).
|
||||
cp "$CLIENT_SO" "$BUILD/lib/$ABI/libmain.so"
|
||||
"$TOOLCHAIN/llvm-strip" --strip-debug "$BUILD/lib/$ABI/libSDL3.so" 2>/dev/null || true
|
||||
"$TOOLCHAIN/llvm-strip" --strip-debug "$BUILD/lib/$ABI/libmain.so" 2>/dev/null || true
|
||||
|
||||
(cd "$BUILD/dex" && zip -q "$BUILD/unsigned.apk" classes.dex)
|
||||
(cd "$BUILD" && zip -q -r "$BUILD/unsigned.apk" "lib")
|
||||
|
||||
echo "== zipalign"
|
||||
"$BUILD_TOOLS/zipalign" -f 4 "$BUILD/unsigned.apk" "$BUILD/aligned.apk"
|
||||
|
||||
KEYSTORE="$ANDROID_DIR/debug.keystore"
|
||||
if [[ ! -f "$KEYSTORE" ]]; then
|
||||
echo "== debug keystore"
|
||||
keytool -genkeypair -v -keystore "$KEYSTORE" \
|
||||
-storepass android -keypass android \
|
||||
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=Android Debug,O=Android,C=US" >/dev/null
|
||||
fi
|
||||
|
||||
echo "== apksigner"
|
||||
"$BUILD_TOOLS/apksigner" sign \
|
||||
--ks "$KEYSTORE" --ks-pass pass:android --key-pass pass:android \
|
||||
--ks-key-alias androiddebugkey \
|
||||
--out "$OUT" "$BUILD/aligned.apk"
|
||||
"$BUILD_TOOLS/apksigner" verify --verbose "$OUT"
|
||||
|
||||
echo
|
||||
echo "APK: $OUT"
|
||||
echo "install: adb install -r \"$OUT\""
|
||||
|
|
@ -23,8 +23,22 @@ crc32fast.workspace = true
|
|||
png = "0.18"
|
||||
pollster.workspace = true
|
||||
sdl3.workspace = true
|
||||
steamworks = "0.13"
|
||||
tracing.workspace = true
|
||||
wgpu.workspace = true
|
||||
glua = { version = "0.1.0", path = "../lua" }
|
||||
|
||||
# Steamworks есть только под нативную платформу: на Android его нет.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
mlua = { version = "0.11.6", features = ["luajit", "vendored"] }
|
||||
steamworks = "0.13"
|
||||
|
||||
# На Android LuaJIT не собирается кросс-компилятором mlua, поэтому берём
|
||||
# обычный Lua 5.4 (vendored): API GMod-скриптов от этого не страдает.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
mlua = { version = "0.11.6", features = ["lua54", "vendored"] }
|
||||
|
||||
# CDylib нужен Android: SDLActivity грузит `libmain.so` и ищет `SDL_main`.
|
||||
[lib]
|
||||
name = "client"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
|
|
|||
236
crates/client/src/lib.rs
Normal file
236
crates/client/src/lib.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
//! Клиент Source RS: общий запуск для нативного бинаря и Android-библиотеки.
|
||||
|
||||
mod convars;
|
||||
mod doctor;
|
||||
mod host;
|
||||
mod lua_cmd;
|
||||
mod map;
|
||||
mod menu;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod net_cmd;
|
||||
mod touch;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "gmod-rs",
|
||||
version,
|
||||
about = "Source RS — клиент Garry's Mod на Rust"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Корень установки Garry's Mod (например, ~/gmod_server/server)
|
||||
#[arg(long, global = true, value_name = "PATH")]
|
||||
assets: Option<PathBuf>,
|
||||
|
||||
/// Подробность логов: -v — debug, -vv — trace
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Главное меню: выбор карты (действие по умолчанию)
|
||||
Run(menu::MenuArgs),
|
||||
|
||||
/// Меню выбора карты
|
||||
Menu(menu::MenuArgs),
|
||||
|
||||
/// Загрузить карту и отрисовать её (полёт камеры)
|
||||
Map(Box<map::MapArgs>),
|
||||
|
||||
/// Сетевые операции (A2S, подключение)
|
||||
#[cfg(not(target_os = "android"))]
|
||||
Net {
|
||||
#[command(subcommand)]
|
||||
cmd: NetCmd,
|
||||
},
|
||||
|
||||
/// Операции с игровой файловой системой
|
||||
Fs {
|
||||
#[command(subcommand)]
|
||||
cmd: FsCmd,
|
||||
},
|
||||
|
||||
/// Lua-скрипты (GMod-подобный рантайм)
|
||||
Lua {
|
||||
#[command(subcommand)]
|
||||
cmd: LuaCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum LuaCmd {
|
||||
/// Выполнить Lua-файл
|
||||
Run(lua_cmd::RunArgs),
|
||||
|
||||
/// Показать autorun-скрипты из ассетов
|
||||
List,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum NetCmd {
|
||||
/// A2S-информация о сервере
|
||||
Info {
|
||||
/// Адрес сервера (`host:port`)
|
||||
addr: String,
|
||||
},
|
||||
/// Тестовое подключение к серверу (хендшейк + события)
|
||||
Connect(net_cmd::ConnectArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum FsCmd {
|
||||
/// Найти ассеты, разобрать VPK, проверить выборку CRC
|
||||
Doctor,
|
||||
|
||||
/// Показать файлы, найденные в смонтированных VPK
|
||||
List {
|
||||
/// Подстрока пути (регистронезависимо); пусто — все
|
||||
pattern: Option<String>,
|
||||
|
||||
/// Максимум строк вывода
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// Определить формат файла (BSP/VTF/VMT/...) и показать сводку
|
||||
Inspect {
|
||||
/// Путь на диске или внутри игры (например, materials/brick/brickfloor001a.vtf)
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Вывести файл из смонтированных путей
|
||||
Cat {
|
||||
/// Путь внутри игры, например models/props_c17/oildrum001.mdl
|
||||
path: String,
|
||||
|
||||
/// Не проверять CRC
|
||||
#[arg(long)]
|
||||
unverified: bool,
|
||||
|
||||
/// Сохранить в файл вместо вывода
|
||||
#[arg(long, value_name = "PATH")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Точка входа: разбирает аргументы и выполняет команду.
|
||||
pub fn run<I, T>(args: I) -> anyhow::Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<std::ffi::OsString> + Clone,
|
||||
{
|
||||
let cli = Cli::parse_from(args);
|
||||
tier::logging::init(cli.verbose);
|
||||
convars::register_all();
|
||||
|
||||
match cli
|
||||
.command
|
||||
.unwrap_or(Command::Run(menu::MenuArgs::default()))
|
||||
{
|
||||
Command::Run(args) | Command::Menu(args) => menu::menu(cli.assets, args),
|
||||
Command::Map(args) => map::map(cli.assets, *args),
|
||||
#[cfg(not(target_os = "android"))]
|
||||
Command::Net { cmd } => match cmd {
|
||||
NetCmd::Info { addr } => net_cmd::info(&addr),
|
||||
NetCmd::Connect(args) => net_cmd::connect(args),
|
||||
},
|
||||
Command::Lua { cmd } => match cmd {
|
||||
LuaCmd::Run(args) => lua_cmd::run(cli.assets, args),
|
||||
LuaCmd::List => lua_cmd::list(cli.assets),
|
||||
},
|
||||
Command::Fs { cmd } => match cmd {
|
||||
FsCmd::Doctor => doctor::doctor(cli.assets),
|
||||
FsCmd::List { pattern, limit } => doctor::list(cli.assets, pattern, limit),
|
||||
FsCmd::Inspect { path } => doctor::inspect(cli.assets, &path),
|
||||
FsCmd::Cat {
|
||||
path,
|
||||
unverified,
|
||||
out,
|
||||
} => doctor::cat(cli.assets, &path, unverified, out),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Каталог ассетов по умолчанию на Android: внешний каталог приложения.
|
||||
///
|
||||
/// Пакет задан в `android/AndroidManifest.xml`; при смене пакета путь нужно
|
||||
/// поправить (или задать `GMOD_RS_ASSETS`).
|
||||
#[cfg(target_os = "android")]
|
||||
pub const ANDROID_DEFAULT_ASSETS: &str =
|
||||
"/sdcard/Android/data/org.gmodrs.client/files/gmod_server/server";
|
||||
|
||||
/// Точка входа SDL на Android.
|
||||
///
|
||||
/// Java-обвязка SDL (`SDLActivity`) загружает `libSDL3.so` и `libmain.so` и
|
||||
/// вызывает этот символ. По умолчанию открываем `gm_construct` с ассетами из
|
||||
/// внешнего каталога приложения; аргументы можно передать из Java.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Вызывается только из SDL: `argv` — массив из `argc` валидных
|
||||
/// NUL-терминированных строк.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn SDL_main(
|
||||
argc: std::os::raw::c_int,
|
||||
argv: *mut *mut std::os::raw::c_char,
|
||||
) -> std::os::raw::c_int {
|
||||
let mut args: Vec<std::ffi::OsString> = Vec::new();
|
||||
for index in 0..argc.max(0) as usize {
|
||||
// SAFETY: SDL передаёт массив argc указателей на NUL-терминированные
|
||||
// строки, живущие всё время вызова.
|
||||
let Some(ptr) = (unsafe { argv.add(index).as_ref() }) else {
|
||||
continue;
|
||||
};
|
||||
let Some(ptr) = std::ptr::NonNull::new(*ptr) else {
|
||||
continue;
|
||||
};
|
||||
// SAFETY: указатель из argv SDL; проверка на NULL выше.
|
||||
let text = unsafe { std::ffi::CStr::from_ptr(ptr.as_ptr()) };
|
||||
args.push(std::ffi::OsString::from(
|
||||
text.to_string_lossy().into_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let assets = std::env::var_os("GMOD_RS_ASSETS")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_dir())
|
||||
.or_else(|| {
|
||||
let default = PathBuf::from(ANDROID_DEFAULT_ASSETS);
|
||||
default.is_dir().then_some(default)
|
||||
});
|
||||
|
||||
if args.len() <= 1 {
|
||||
args = vec!["gmod-rs".into(), "map".into(), "gm_construct".into()];
|
||||
if let Some(assets) = assets {
|
||||
args.push("--assets".into());
|
||||
args.push(assets.into_os_string());
|
||||
}
|
||||
}
|
||||
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(args))) {
|
||||
Ok(Ok(())) => 0,
|
||||
Ok(Err(err)) => {
|
||||
tracing::error!("{err:#}");
|
||||
1
|
||||
}
|
||||
Err(payload) => {
|
||||
// Паника не должна проходить через FFI-границу SDL: ловим её и
|
||||
// отдаём код ошибки, как того требует правило проекта.
|
||||
let message = payload
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("неизвестная паника");
|
||||
tracing::error!("паника в SDL_main: {message}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +1,5 @@
|
|||
//! Source RS — клиент Garry's Mod, переписанный на Rust.
|
||||
|
||||
mod convars;
|
||||
mod doctor;
|
||||
mod host;
|
||||
mod lua_cmd;
|
||||
mod map;
|
||||
mod menu;
|
||||
mod net_cmd;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "gmod-rs",
|
||||
version,
|
||||
about = "Source RS — клиент Garry's Mod на Rust"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Корень установки Garry's Mod (например, ~/gmod_server/server)
|
||||
#[arg(long, global = true, value_name = "PATH")]
|
||||
assets: Option<PathBuf>,
|
||||
|
||||
/// Подробность логов: -v — debug, -vv — trace
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Главное меню: выбор карты (действие по умолчанию)
|
||||
Run(menu::MenuArgs),
|
||||
|
||||
/// Меню выбора карты
|
||||
Menu(menu::MenuArgs),
|
||||
|
||||
/// Загрузить карту и отрисовать её (полёт камеры)
|
||||
Map(Box<map::MapArgs>),
|
||||
|
||||
/// Сетевые операции (A2S, подключение)
|
||||
Net {
|
||||
#[command(subcommand)]
|
||||
cmd: NetCmd,
|
||||
},
|
||||
|
||||
/// Операции с игровой файловой системой
|
||||
Fs {
|
||||
#[command(subcommand)]
|
||||
cmd: FsCmd,
|
||||
},
|
||||
|
||||
/// Lua-скрипты (GMod-подобный рантайм)
|
||||
Lua {
|
||||
#[command(subcommand)]
|
||||
cmd: LuaCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum LuaCmd {
|
||||
/// Выполнить Lua-файл
|
||||
Run(lua_cmd::RunArgs),
|
||||
|
||||
/// Показать autorun-скрипты из ассетов
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum NetCmd {
|
||||
/// A2S-информация о сервере
|
||||
Info {
|
||||
/// Адрес сервера (`host:port`)
|
||||
addr: String,
|
||||
},
|
||||
/// Тестовое подключение к серверу (хендшейк + события)
|
||||
Connect(net_cmd::ConnectArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum FsCmd {
|
||||
/// Найти ассеты, разобрать VPK, проверить выборку CRC
|
||||
Doctor,
|
||||
|
||||
/// Показать файлы, найденные в смонтированных VPK
|
||||
List {
|
||||
/// Подстрока пути (регистронезависимо); пусто — все
|
||||
pattern: Option<String>,
|
||||
|
||||
/// Максимум строк вывода
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// Определить формат файла (BSP/VTF/VMT/...) и показать сводку
|
||||
Inspect {
|
||||
/// Путь на диске или внутри игры (например, materials/brick/brickfloor001a.vtf)
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Вывести файл из смонтированных путей
|
||||
Cat {
|
||||
/// Путь внутри игры, например models/props_c17/oildrum001.mdl
|
||||
path: String,
|
||||
|
||||
/// Не проверять CRC
|
||||
#[arg(long)]
|
||||
unverified: bool,
|
||||
|
||||
/// Сохранить в файл вместо вывода
|
||||
#[arg(long, value_name = "PATH")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
tier::logging::init(cli.verbose);
|
||||
convars::register_all();
|
||||
|
||||
match cli
|
||||
.command
|
||||
.unwrap_or(Command::Run(menu::MenuArgs::default()))
|
||||
{
|
||||
Command::Run(args) | Command::Menu(args) => menu::menu(cli.assets, args),
|
||||
Command::Map(args) => map::map(cli.assets, *args),
|
||||
Command::Net { cmd } => match cmd {
|
||||
NetCmd::Info { addr } => net_cmd::info(&addr),
|
||||
NetCmd::Connect(args) => net_cmd::connect(args),
|
||||
},
|
||||
Command::Lua { cmd } => match cmd {
|
||||
LuaCmd::Run(args) => lua_cmd::run(cli.assets, args),
|
||||
LuaCmd::List => lua_cmd::list(cli.assets),
|
||||
},
|
||||
Command::Fs { cmd } => match cmd {
|
||||
FsCmd::Doctor => doctor::doctor(cli.assets),
|
||||
FsCmd::List { pattern, limit } => doctor::list(cli.assets, pattern, limit),
|
||||
FsCmd::Inspect { path } => doctor::inspect(cli.assets, &path),
|
||||
FsCmd::Cat {
|
||||
path,
|
||||
unverified,
|
||||
out,
|
||||
} => doctor::cat(cli.assets, &path, unverified, out),
|
||||
},
|
||||
}
|
||||
client::run(std::env::args_os())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Команда `map`: загрузка карты и рендер с полётом камеры.
|
||||
|
||||
use crate::doctor;
|
||||
use crate::touch::TouchControls;
|
||||
use anyhow::Context;
|
||||
use engine::FlyCamera;
|
||||
use fs::VirtualFs;
|
||||
|
|
@ -134,6 +135,10 @@ pub struct MapArgs {
|
|||
/// Начать в режиме свободного полёта (noclip); переключается клавишей V
|
||||
#[arg(long)]
|
||||
pub noclip: bool,
|
||||
|
||||
/// Включить сенсорное управление (на Android включено всегда)
|
||||
#[arg(long)]
|
||||
pub touch: bool,
|
||||
}
|
||||
|
||||
impl MapArgs {
|
||||
|
|
@ -161,6 +166,7 @@ impl MapArgs {
|
|||
only_material: None,
|
||||
dump_textures: None,
|
||||
noclip: false,
|
||||
touch: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -407,7 +413,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
.event_pump()
|
||||
.map_err(|e| anyhow::anyhow!("SDL3 event pump: {e}"))?;
|
||||
let mut pressed: HashSet<Keycode> = HashSet::new();
|
||||
let relative_mouse = !args.no_mouse && !args.hidden && args.frames.is_none();
|
||||
let touch_enabled = args.touch || cfg!(target_os = "android");
|
||||
if touch_enabled {
|
||||
// SDL иначе дублирует касания синтетическими событиями мыши: на
|
||||
// телефоне это давало бы «выстрел физганом» при каждом свайпе.
|
||||
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
||||
}
|
||||
let mut touch = TouchControls::new(touch_enabled);
|
||||
let relative_mouse = !args.no_mouse && !args.hidden && args.frames.is_none() && !touch_enabled;
|
||||
if relative_mouse {
|
||||
sdl.mouse().set_relative_mouse_mode(&window, true);
|
||||
}
|
||||
|
|
@ -690,6 +703,34 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
// Source: +Y — влево, поэтому движение мыши вправо уменьшает yaw.
|
||||
camera.rotate(-xrel * 0.15, yrel * 0.15);
|
||||
}
|
||||
Event::FingerDown {
|
||||
finger_id, x, y, ..
|
||||
} => {
|
||||
touch.finger_down(finger_id, x, y);
|
||||
}
|
||||
Event::FingerMotion {
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
dx,
|
||||
dy,
|
||||
..
|
||||
} => {
|
||||
if let Some((yaw, pitch)) = touch.finger_motion(finger_id, x, y, dx, dy) {
|
||||
camera.rotate(yaw, pitch);
|
||||
}
|
||||
}
|
||||
Event::FingerUp { finger_id, .. } | Event::FingerCanceled { finger_id, .. } => {
|
||||
touch.finger_up(finger_id);
|
||||
}
|
||||
Event::Window {
|
||||
win_event: WindowEvent::FocusLost,
|
||||
..
|
||||
} => {
|
||||
// При сворачивании Android приходит cancel не на все пальцы:
|
||||
// иначе игрок «уезжает» с зажатым стиком.
|
||||
touch.cancel();
|
||||
}
|
||||
Event::Window {
|
||||
win_event: WindowEvent::PixelSizeChanged(w, h) | WindowEvent::Resized(w, h),
|
||||
..
|
||||
|
|
@ -781,6 +822,15 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
if pressed.contains(&Keycode::LCtrl) {
|
||||
move_up -= 1.0;
|
||||
}
|
||||
// Сенсорный стик складывается с клавиатурой: на Android клавиш нет,
|
||||
// а на десктопе `--touch` удобно проверять рядом с WASD.
|
||||
let (touch_forward, touch_right) = touch.movement();
|
||||
move_forward = (move_forward + touch_forward).clamp(-1.0, 1.0);
|
||||
move_right = (move_right + touch_right).clamp(-1.0, 1.0);
|
||||
if touch.duck() {
|
||||
move_up -= 1.0;
|
||||
}
|
||||
let touch_jump = touch.take_jump();
|
||||
if player.noclip {
|
||||
let speed = if pressed.contains(&Keycode::LShift) {
|
||||
args.speed * 3.0
|
||||
|
|
@ -794,14 +844,16 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
camera.yaw,
|
||||
camera.pitch,
|
||||
speed,
|
||||
pressed.contains(&Keycode::Space),
|
||||
pressed.contains(&Keycode::Space) || touch_jump,
|
||||
dt,
|
||||
);
|
||||
} else {
|
||||
let jump = pressed.contains(&Keycode::Space);
|
||||
let duck = pressed.contains(&Keycode::LCtrl);
|
||||
// Shift — бег (`+speed` в Source).
|
||||
player.sprint = pressed.contains(&Keycode::LShift);
|
||||
let jump = pressed.contains(&Keycode::Space) || touch_jump;
|
||||
let duck = pressed.contains(&Keycode::LCtrl) || touch.duck();
|
||||
// Shift — бег (`+speed` в Source). На телефоне отдельной кнопки нет:
|
||||
// движение стиком сразу бегом, иначе до интересного далеко.
|
||||
player.sprint = pressed.contains(&Keycode::LShift)
|
||||
|| (touch_enabled && (touch_forward.abs() + touch_right.abs()) > 0.1);
|
||||
player.update(
|
||||
&collision,
|
||||
move_forward,
|
||||
|
|
@ -957,7 +1009,21 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
// HUD (тень + текст, чтобы читалось на любом фоне).
|
||||
let (_, height) = gfx.size();
|
||||
let (width, height) = gfx.size();
|
||||
// Android рисует поверх всего экрана, включая системные панели:
|
||||
// сдвигаем HUD внутрь, иначе строки уезжают под статус-бар.
|
||||
let hud_top = if cfg!(target_os = "android") {
|
||||
96.0
|
||||
} else {
|
||||
8.0
|
||||
};
|
||||
let hud_bottom = if cfg!(target_os = "android") {
|
||||
// Нижняя системная панель на Android выше, чем кажется: держим
|
||||
// строку над ней, иначе подсказка уходит под чёрную полосу.
|
||||
height as f32 - 110.0
|
||||
} else {
|
||||
height as f32 - 28.0
|
||||
};
|
||||
fps = fps * 0.9 + (1.0 / dt.max(1e-4)) * 0.1;
|
||||
renderer.clear_text();
|
||||
let mut hud = |x: f32, y: f32, scale: f32, color: [f32; 4], text: String| {
|
||||
|
|
@ -966,14 +1032,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
};
|
||||
hud(
|
||||
8.0,
|
||||
8.0,
|
||||
hud_top,
|
||||
1.5,
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
format!("FPS {:.0}", fps),
|
||||
);
|
||||
hud(
|
||||
8.0,
|
||||
28.0,
|
||||
hud_top + 20.0,
|
||||
1.5,
|
||||
[0.85, 0.95, 1.0, 1.0],
|
||||
format!(
|
||||
|
|
@ -992,13 +1058,42 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
);
|
||||
hud(
|
||||
8.0,
|
||||
height as f32 - 28.0,
|
||||
hud_bottom,
|
||||
1.5,
|
||||
[0.95, 0.95, 0.8, 1.0],
|
||||
"WASD move | Space jump | Ctrl duck | V noclip | ` log".to_string(),
|
||||
if touch_enabled {
|
||||
"touch: left stick move | right swipe look | buttons jump/duck".to_string()
|
||||
} else {
|
||||
"WASD move | Space jump | Ctrl duck | V noclip | ` log".to_string()
|
||||
},
|
||||
);
|
||||
if touch_enabled {
|
||||
// Подписи зон: сами зоны заданы в `touch.rs`, здесь только
|
||||
// ориентиры, чтобы на телефоне было понятно, куда нажимать.
|
||||
hud(
|
||||
width as f32 * 0.06,
|
||||
height as f32 * 0.80,
|
||||
1.4,
|
||||
[0.75, 0.9, 1.0, 0.75],
|
||||
"MOVE".to_string(),
|
||||
);
|
||||
hud(
|
||||
width as f32 * 0.74,
|
||||
height as f32 * 0.60,
|
||||
1.6,
|
||||
[0.8, 1.0, 0.8, 0.8],
|
||||
"JUMP".to_string(),
|
||||
);
|
||||
hud(
|
||||
width as f32 * 0.74,
|
||||
height as f32 * 0.86,
|
||||
1.6,
|
||||
[1.0, 0.9, 0.7, 0.8],
|
||||
"DUCK".to_string(),
|
||||
);
|
||||
}
|
||||
if show_log {
|
||||
let mut y = height as f32 - 60.0;
|
||||
let mut y = hud_bottom - 32.0;
|
||||
for line in log.iter().rev().take(4) {
|
||||
hud(8.0, y, 1.2, [0.8, 1.0, 0.8, 0.95], line.clone());
|
||||
y -= 16.0;
|
||||
|
|
|
|||
233
crates/client/src/touch.rs
Normal file
233
crates/client/src/touch.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//! Виртуальные органы управления для сенсорного экрана.
|
||||
//!
|
||||
//! Левый стик — ходьба (те же `wish_forward`/`wish_right`, что у WASD),
|
||||
//! правая половина — обзор (свайп = мышь), кнопки справа снизу — прыжок и
|
||||
//! присед. Касания обрабатываются в общем цикле `map`, поэтому на телефоне
|
||||
//! работают те же правила движения, коллизий и физики, что и с клавиатуры.
|
||||
|
||||
/// Координаты касаний SDL нормализованы в 0..1 по окну.
|
||||
const STICK_RADIUS: f32 = 0.16;
|
||||
const STICK_DEADZONE: f32 = 0.10;
|
||||
/// Полный свайп по ширине экрана поворачивает камеру на столько градусов.
|
||||
const LOOK_DEGREES_PER_SCREEN: f32 = 180.0;
|
||||
|
||||
/// Прямоугольник кнопки в нормализованных координатах.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ButtonRect {
|
||||
min_x: f32,
|
||||
max_x: f32,
|
||||
min_y: f32,
|
||||
max_y: f32,
|
||||
}
|
||||
|
||||
impl ButtonRect {
|
||||
fn contains(&self, x: f32, y: f32) -> bool {
|
||||
(self.min_x..=self.max_x).contains(&x) && (self.min_y..=self.max_y).contains(&y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Прыжок: широкая кнопка справа снизу.
|
||||
const JUMP_BUTTON: ButtonRect = ButtonRect {
|
||||
min_x: 0.70,
|
||||
max_x: 0.93,
|
||||
min_y: 0.50,
|
||||
max_y: 0.75,
|
||||
};
|
||||
|
||||
/// Присед: под прыжком.
|
||||
const DUCK_BUTTON: ButtonRect = ButtonRect {
|
||||
min_x: 0.70,
|
||||
max_x: 0.93,
|
||||
min_y: 0.78,
|
||||
max_y: 0.97,
|
||||
};
|
||||
|
||||
/// Состояние сенсорного управления.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TouchControls {
|
||||
enabled: bool,
|
||||
move_finger: Option<u64>,
|
||||
move_origin: (f32, f32),
|
||||
move_vector: (f32, f32),
|
||||
look_finger: Option<u64>,
|
||||
jump_finger: Option<u64>,
|
||||
duck_finger: Option<u64>,
|
||||
/// Короткое касание кнопки прыжка могло начаться и кончиться между
|
||||
/// кадрами — запоминаем его до ближайшего кадра.
|
||||
jump_pending: bool,
|
||||
}
|
||||
|
||||
impl TouchControls {
|
||||
/// Включает обработку касаний (на Android — всегда).
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает true, если касание было поглощено сенсорным управлением.
|
||||
pub fn finger_down(&mut self, finger_id: u64, x: f32, y: f32) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
if JUMP_BUTTON.contains(x, y) {
|
||||
self.jump_finger = Some(finger_id);
|
||||
self.jump_pending = true;
|
||||
return true;
|
||||
}
|
||||
if DUCK_BUTTON.contains(x, y) {
|
||||
self.duck_finger = Some(finger_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Левая половина — стик; правая — обзор. Второй палец на левой
|
||||
// половине тоже может крутить обзор.
|
||||
if x < 0.45 && self.move_finger.is_none() {
|
||||
self.move_finger = Some(finger_id);
|
||||
self.move_origin = (x, y);
|
||||
self.move_vector = (0.0, 0.0);
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.look_finger.is_none() {
|
||||
self.look_finger = Some(finger_id);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Обрабатывает движение пальца; возвращает приращение обзора в градусах
|
||||
/// `(yaw, pitch)`, если этот палец крутит камеру.
|
||||
pub fn finger_motion(
|
||||
&mut self,
|
||||
finger_id: u64,
|
||||
x: f32,
|
||||
y: f32,
|
||||
dx: f32,
|
||||
dy: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
if self.move_finger == Some(finger_id) {
|
||||
let rel_x = x - self.move_origin.0;
|
||||
let rel_y = y - self.move_origin.1;
|
||||
let length = (rel_x * rel_x + rel_y * rel_y).sqrt();
|
||||
let scale = (length - STICK_DEADZONE).max(0.0) / (STICK_RADIUS - STICK_DEADZONE);
|
||||
let scale = scale.clamp(0.0, 1.0);
|
||||
let (nx, ny) = if length > 1e-5 {
|
||||
(rel_x / length, rel_y / length)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
// Вверх по экрану — вперёд (Source: +Y смотрит влево, поэтому
|
||||
// движение вправо уменьшает yaw, а стик — обычный экранный X).
|
||||
self.move_vector = (-ny * scale, nx * scale);
|
||||
return None;
|
||||
}
|
||||
if self.look_finger == Some(finger_id) {
|
||||
let _ = (x, y);
|
||||
return Some((-dx * LOOK_DEGREES_PER_SCREEN, dy * LOOK_DEGREES_PER_SCREEN));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Отпускание пальца. Возвращает true, если палец был занят управлением.
|
||||
pub fn finger_up(&mut self, finger_id: u64) -> bool {
|
||||
let mut handled = false;
|
||||
if self.move_finger == Some(finger_id) {
|
||||
self.move_finger = None;
|
||||
self.move_vector = (0.0, 0.0);
|
||||
handled = true;
|
||||
}
|
||||
if self.look_finger == Some(finger_id) {
|
||||
self.look_finger = None;
|
||||
handled = true;
|
||||
}
|
||||
if self.jump_finger == Some(finger_id) {
|
||||
self.jump_finger = None;
|
||||
handled = true;
|
||||
}
|
||||
if self.duck_finger == Some(finger_id) {
|
||||
self.duck_finger = None;
|
||||
handled = true;
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
/// Отмена всех касаний (потеря фокуса, сворачивание приложения).
|
||||
pub fn cancel(&mut self) {
|
||||
self.move_finger = None;
|
||||
self.look_finger = None;
|
||||
self.jump_finger = None;
|
||||
self.duck_finger = None;
|
||||
self.jump_pending = false;
|
||||
self.move_vector = (0.0, 0.0);
|
||||
}
|
||||
|
||||
/// Текущий стик: `(forward, right)` в диапазоне -1..1.
|
||||
pub fn movement(&self) -> (f32, f32) {
|
||||
if !self.enabled {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
self.move_vector
|
||||
}
|
||||
|
||||
/// Прыжок на этот кадр: кнопка удерживается или была коротко нажата.
|
||||
pub fn take_jump(&mut self) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
let jump = self.jump_finger.is_some() || self.jump_pending;
|
||||
self.jump_pending = false;
|
||||
jump
|
||||
}
|
||||
|
||||
pub fn duck(&self) -> bool {
|
||||
self.enabled && self.duck_finger.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stick_moves_forward_and_releases() {
|
||||
let mut touch = TouchControls::new(true);
|
||||
assert!(touch.finger_down(1, 0.2, 0.7));
|
||||
// Палец вверх от центра — движение вперёд.
|
||||
touch.finger_motion(1, 0.2, 0.7 - STICK_RADIUS, 0.0, -STICK_RADIUS);
|
||||
let (forward, right) = touch.movement();
|
||||
assert!(forward > 0.9, "forward={forward}");
|
||||
assert!(right.abs() < 0.01, "right={right}");
|
||||
assert!(touch.finger_up(1));
|
||||
assert_eq!(touch.movement(), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_rotates_and_buttons_hold() {
|
||||
let mut touch = TouchControls::new(true);
|
||||
assert!(touch.finger_down(2, 0.8, 0.3));
|
||||
let (yaw, pitch) = touch.finger_motion(2, 0.9, 0.3, 0.1, 0.0).expect("look");
|
||||
assert!(yaw < 0.0);
|
||||
assert_eq!(pitch, 0.0);
|
||||
assert!(touch.finger_up(2));
|
||||
|
||||
assert!(touch.finger_down(3, 0.8, 0.6));
|
||||
assert!(touch.take_jump());
|
||||
assert!(!touch.duck());
|
||||
assert!(touch.finger_up(3));
|
||||
assert!(!touch.take_jump());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_ignores_touches() {
|
||||
let mut touch = TouchControls::new(false);
|
||||
assert!(!touch.finger_down(1, 0.2, 0.8));
|
||||
assert_eq!(touch.movement(), (0.0, 0.0));
|
||||
assert!(!touch.take_jump());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,5 +5,10 @@ edition.workspace = true
|
|||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
mlua = { version = "0.11.6", features = ["luajit", "vendored"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
mlua = { version = "0.11.6", features = ["luajit", "vendored"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
mlua = { version = "0.11.6", features = ["lua54", "vendored"] }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
//! Инициализация логирования (tracing).
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::Once;
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
|
@ -25,11 +24,59 @@ pub fn init(verbosity: u8) {
|
|||
EnvFilter::new(level)
|
||||
});
|
||||
|
||||
let ansi = std::io::stdout().is_terminal();
|
||||
let _ = tracing_subscriber::fmt()
|
||||
let builder = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.with_ansi(ansi)
|
||||
.with_target(true);
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = {
|
||||
use std::io::IsTerminal;
|
||||
let ansi = std::io::stdout().is_terminal();
|
||||
builder.with_ansi(ansi).try_init()
|
||||
};
|
||||
|
||||
// На Android stdout/stderr не видны: пишем строки в logcat
|
||||
// (`adb logcat -s gmod-rs`), иначе диагностика запуска теряется.
|
||||
#[cfg(target_os = "android")]
|
||||
let _ = builder
|
||||
.with_ansi(false)
|
||||
.with_writer(|| android_logcat::LogcatWriter)
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_logcat {
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[link(name = "log")]
|
||||
unsafe extern "C" {
|
||||
fn __android_log_write(
|
||||
priority: i32,
|
||||
tag: *const std::os::raw::c_char,
|
||||
text: *const std::os::raw::c_char,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
/// Писатель в Android logcat (приоритет INFO, тег `gmod-rs`).
|
||||
pub struct LogcatWriter;
|
||||
|
||||
impl Write for LogcatWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
// logcat принимает C-строку: выкидываем NUL из форматированной
|
||||
// строки, чтобы лог не обрезался на середине.
|
||||
let mut text: Vec<u8> = buf.iter().copied().filter(|&b| b != 0).collect();
|
||||
text.push(0);
|
||||
// SAFETY: tag и text — корректные NUL-терминированные строки,
|
||||
// живут до конца вызова.
|
||||
unsafe {
|
||||
__android_log_write(4, c"gmod-rs".as_ptr(), text.as_ptr().cast());
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
docs/ANDROID.md
Normal file
61
docs/ANDROID.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Android-порт клиента
|
||||
|
||||
Цель — тот же клиент `gmod-rs` (SDL3 + wgpu), запускаемый как APK на Android
|
||||
12+ (API 31+). Отдельного «мобильного движка» нет: карта, физика, Lua и рендер
|
||||
общие с десктопом.
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
./android/build.sh # arm64-v8a, APK для телефона
|
||||
ANDROID_ABI=x86_64 ./android/build.sh # x86_64, для эмулятора
|
||||
```
|
||||
|
||||
Скрипт использует SDK/NDK, `cargo`, `aapt2`, `d8`, `zipalign`, `apksigner`
|
||||
(Gradle не нужен). Результат — `target/android/gmod-rs-debug.apk`
|
||||
(подписан debug-ключом `android/debug.keystore`). Подробности и команды
|
||||
установки — в `android/README.md`.
|
||||
|
||||
## Крейты и target cfg
|
||||
|
||||
- `crates/client` теперь и библиотека (`src/lib.rs`), и бинарь (`src/main.rs`).
|
||||
Бинарь вызывает `client::run(args)`, Android-сборка — `cargo build --lib`
|
||||
с `crate-type = ["cdylib", "rlib"]`.
|
||||
- Экспорт `SDL_main` (в `lib.rs` под `cfg(target_os = "android")`) — точка
|
||||
входа для штатной Java-обвязки SDL3 (`SDLActivity`).
|
||||
- `steamworks` подключён только для не-Android; команда `net` на Android
|
||||
отсутствует.
|
||||
- `mlua`: на десктопе LuaJIT + `vendored`, на Android — Lua 5.4 + `vendored`
|
||||
(LuaJIT не собирается кросс-компилятором mlua). API GMod-скриптов сохраняется.
|
||||
- `tracing` на Android пишет в logcat (`adb logcat -s gmod-rs`), а не в stdout.
|
||||
|
||||
## APK
|
||||
|
||||
- Java-классы SDL3 (`org.libsdl.app.*`) взяты из `sdl3-src` (zlib-лицензия) и
|
||||
лежат в `android/app/src/main/java`.
|
||||
- `MainActivity` наследует `SDLActivity`, фиксирует ландшафт и передаёт
|
||||
нативной части аргументы `map gm_construct --assets <external files>/...`.
|
||||
- Нативные библиотеки пакуются как `lib/arm64-v8a/libSDL3.so` и
|
||||
`libmain.so` (внутри — `libclient.so` с `SDL_main`).
|
||||
- Ассеты не входят в APK: их кладут во внешний каталог приложения
|
||||
(`Android/data/org.gmodrs.client/files/gmod_server/server`), см. README.
|
||||
|
||||
## Управление
|
||||
|
||||
Обрабатываются `SDL_EVENT_FINGER_*`; синтетические события мыши от касаний
|
||||
отключены (`SDL_TOUCH_MOUSE_EVENTS=0`):
|
||||
|
||||
- левая половина экрана — аналоговый стик (forward/right идут в тот же
|
||||
`Player::update`, что WASD);
|
||||
- правая половина — обзор свайпом (приращение углов камеры);
|
||||
- кнопки `JUMP`/`DUCK` — прыжок (с защёлкой короткого тапа) и присед;
|
||||
- на Android движение сразу бегом (отдельной кнопки Shift нет).
|
||||
|
||||
## Проверка
|
||||
|
||||
- `cargo clippy -p client --lib --target aarch64-linux-android -- -D warnings`.
|
||||
- Эмулятор API 35 x86_64: APK запускается, `gm_construct` грузится, кадры
|
||||
рисуются (SwiftShader, ~11–14 fps), стик/обзор/прыжок проверены через
|
||||
`adb shell input`.
|
||||
- Реальное arm64-устройство не проверялось (нет устройства); при отладке —
|
||||
`adb logcat -s gmod-rs SDL`.
|
||||
|
|
@ -22,7 +22,8 @@ crates/
|
|||
ui — нативный UI: меню, консоль, шрифты, HUD-рамка, загрузочные экраны
|
||||
lua — mlua (vendored LuaJIT), sandbox, рельсы API, мосты к engine/render/studio
|
||||
steam — фичи steamworks-rs (тикет, UGC/workshop), отдельный steam-поток
|
||||
client — бинарь `gmod-rs`: сборка всего, главный цикл, конфиг, CLI
|
||||
client — бинарь `gmod-rs` и библиотека: сборка всего, главный цикл,
|
||||
конфиг, CLI; на Android — cdylib с `SDL_main` (см. `ANDROID.md`)
|
||||
```
|
||||
|
||||
### Правило зависимостей
|
||||
|
|
@ -158,6 +159,19 @@ texture streaming и VRAM-бюджет (с M9).
|
|||
- Авто-детект ассетов: (1) явный путь; (2) `~/gmod_server/server`; (3) Steam common.
|
||||
- Внутриигровые настройки — конвары (как в Source), сохраняются в `cfg/`.
|
||||
|
||||
### 3.10 Android
|
||||
|
||||
- Тот же клиент собирается как `cdylib` и грузится штатной `SDLActivity` из
|
||||
SDL3; экспорт — `SDL_main`. Сборка APK — `android/build.sh` (aapt2 + d8,
|
||||
без Gradle), debug-подпись.
|
||||
- Целевые ABI: `arm64-v8a` (телефон) и `x86_64` (эмулятор). `minSdk 31`,
|
||||
`targetSdk 35`, фиксированный ландшафт.
|
||||
- `target.'cfg(target_os = "android")'`: без `steamworks`, Lua 5.4 вместо
|
||||
LuaJIT; логирование в logcat.
|
||||
- Ассеты — во внешнем каталоге приложения; касания (`FingerDown/Motion/Up`)
|
||||
переведены в те же `wish_forward/right`, прыжок и присед, что клавиатура.
|
||||
- Детали и ограничения — `docs/ANDROID.md`, команды установки — `android/README.md`.
|
||||
|
||||
## 4. Конвенции кода
|
||||
|
||||
1. `unwrap/expect/panic` запрещены в путях обработки внешних данных (сеть/диск/Lua).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue