Слияние feat/android: APK под Android 12+, сенсорное управление
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:
Niko Marmeladkov 2026-10-23 14:22:56 +03:00
commit 2edea0125f
26 changed files with 7117 additions and 165 deletions

2
android/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
build/
debug.keystore

View 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
View 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`.

View file

@ -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()
};
}
}

View 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();
}

View file

@ -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();
}
}

View 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);
}

View 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);
}
}
}
}
}
}

View 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;
}

File diff suppressed because it is too large Load diff

View 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);
}

File diff suppressed because it is too large Load diff

View 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;
}
}

View 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);
}

View file

@ -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;
}
}
}
}
}

View 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
View 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\""

View file

@ -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
View 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
}
}
}

View file

@ -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())
}

View file

@ -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
View 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());
}
}

View file

@ -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"] }

View file

@ -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
View 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`.

View file

@ -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).