diff --git a/README.md b/README.md index 2a2093f..9a43a54 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,54 @@ If you are interested in helping, please reach out! You can use GitHub or our [Discord server](https://discord.gg/pxrBmy4). +## Android Integration + +The Android implementation requires the application's Activity to forward input events (and +input devices) to the plugin. Below is an example of a MainActivity for a clean Flutter project +that has implemented the required boilerplate code. For many projects it will be possible to simply +duplicate this setup. + +```dart +package [YOUR_PACKAGE_NAME] + +import android.hardware.input.InputManager +import android.os.Handler +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import io.flutter.embedding.android.FlutterActivity +import org.flame_engine.gamepads_android.GamepadsCompatibleActivity + +class MainActivity: FlutterActivity(), GamepadsCompatibleActivity { + var keyListener: ((KeyEvent) -> Boolean)? = null + var motionListener: ((MotionEvent) -> Boolean)? = null + + override fun dispatchGenericMotionEvent(motionEvent: MotionEvent): Boolean { + return motionListener?.invoke(motionEvent) ?: false + } + + override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean { + return keyListener?.invoke(keyEvent) ?: false + } + + override fun registerInputDeviceListener( + listener: InputManager.InputDeviceListener, handler: Handler?) { + val inputManager = getSystemService(INPUT_SERVICE) as InputManager + inputManager.registerInputDeviceListener(listener, null) + } + + override fun registerKeyEventHandler(handler: (KeyEvent) -> Boolean) { + keyListener = handler + } + + override fun registerMotionEventHandler(handler: (MotionEvent) -> Boolean) { + motionListener = handler + } +} + +``` + + ## Support The simplest way to show us your support is by giving the project a star! :star: diff --git a/packages/gamepads/pubspec.yaml b/packages/gamepads/pubspec.yaml index a02b101..dd91bd9 100644 --- a/packages/gamepads/pubspec.yaml +++ b/packages/gamepads/pubspec.yaml @@ -7,6 +7,8 @@ repository: https://github.com/flame-engine/gamepads/tree/main/packages/gamepads flutter: plugin: platforms: + android: + default_package: gamepads_android ios: default_package: gamepads_ios linux: @@ -23,6 +25,7 @@ environment: dependencies: flutter: sdk: flutter + gamepads_android: ^0.1.1 gamepads_darwin: ^0.1.1 gamepads_ios: ^0.1.1 gamepads_linux: ^0.1.1 diff --git a/packages/gamepads_android/CHANGELOG.md b/packages/gamepads_android/CHANGELOG.md new file mode 100644 index 0000000..e7ffe85 --- /dev/null +++ b/packages/gamepads_android/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.1 + + - Initial Android version. diff --git a/packages/gamepads_android/LICENSE b/packages/gamepads_android/LICENSE new file mode 100644 index 0000000..5daab9e --- /dev/null +++ b/packages/gamepads_android/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/gamepads_android/README.md b/packages/gamepads_android/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/gamepads_android/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/gamepads_android/analysis_options.yaml b/packages/gamepads_android/analysis_options.yaml new file mode 100644 index 0000000..ba5631f --- /dev/null +++ b/packages/gamepads_android/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/gamepads_android/android/.gitignore b/packages/gamepads_android/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/packages/gamepads_android/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/gamepads_android/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/packages/gamepads_android/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 0000000..539ab02 --- /dev/null +++ b/packages/gamepads_android/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,19 @@ +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + } +} diff --git a/packages/gamepads_android/android/build.gradle b/packages/gamepads_android/android/build.gradle new file mode 100644 index 0000000..905f700 --- /dev/null +++ b/packages/gamepads_android/android/build.gradle @@ -0,0 +1,72 @@ +group 'org.flame_engine.gamepads_android' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'org.flame_engine.gamepads_android' + } + + compileSdk 34 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' + } + + defaultConfig { + minSdkVersion 19 + } + + dependencies { + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.mockito:mockito-core:5.0.0' + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } + + buildFeatures { + prefab true + } +} \ No newline at end of file diff --git a/packages/gamepads_android/android/settings.gradle b/packages/gamepads_android/android/settings.gradle new file mode 100644 index 0000000..88fa0ec --- /dev/null +++ b/packages/gamepads_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'gamepads_android' diff --git a/packages/gamepads_android/android/src/main/AndroidManifest.xml b/packages/gamepads_android/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7c51a80 --- /dev/null +++ b/packages/gamepads_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/DeviceListener.kt b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/DeviceListener.kt new file mode 100644 index 0000000..95131a8 --- /dev/null +++ b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/DeviceListener.kt @@ -0,0 +1,65 @@ +package org.flame_engine.gamepads_android + +import android.hardware.input.InputManager +import android.util.Log +import android.view.InputDevice + +class DeviceListener(val isGamepadsInputDevice: (device: InputDevice) -> Boolean): InputManager.InputDeviceListener { + private val devicesLookup: MutableMap = mutableMapOf() + private val TAG = "ConnectionListener" + + init { + getGameControllerIds() + } + + fun getDevices(): Map { + return devicesLookup.toMap() + } + + private fun getGameControllerIds() { + val gameControllerDeviceIds = mutableListOf() + val deviceIds = InputDevice.getDeviceIds() + deviceIds.forEach { deviceId -> + InputDevice.getDevice(deviceId).apply { + if (this != null) { + if (isGamepadsInputDevice(this)) { + Log.i(TAG, "${this.name} passed input device test") + devicesLookup[deviceId] = this + } else { + Log.e(TAG, "${this.name} failed input device test") + } + } + } + } + } + + fun containsKey(deviceId: Int): Boolean { + return devicesLookup.containsKey(deviceId) + } + + override fun onInputDeviceAdded(deviceId: Int) { + val device: InputDevice? = InputDevice.getDevice(deviceId) + if (device != null) { + if (isGamepadsInputDevice(device)) { + Log.i(TAG, "${device.name} passed input device test") + devicesLookup[deviceId] = device + } else { + Log.e(TAG, "${device.name} failed input device test") + } + } + } + + override fun onInputDeviceRemoved(deviceId: Int) { + val device: InputDevice? = InputDevice.getDevice(deviceId) + devicesLookup.remove(deviceId) + } + + override fun onInputDeviceChanged(deviceId: Int) { + val device: InputDevice? = InputDevice.getDevice(deviceId) + if (device != null && isGamepadsInputDevice(device)) { + devicesLookup[deviceId] = device + } else { + devicesLookup.remove(deviceId) + } + } +} \ No newline at end of file diff --git a/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/EventListener.kt b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/EventListener.kt new file mode 100644 index 0000000..749054b --- /dev/null +++ b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/EventListener.kt @@ -0,0 +1,73 @@ +package org.flame_engine.gamepads_android + +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +import io.flutter.plugin.common.MethodChannel +import kotlin.math.abs + +data class SupportedAxis(val axisId: Int, val invert: Boolean = false) + +class EventListener { + companion object { + private const val TAG = "EventListener" + private const val axisEpisilon = 0.001 + } + private val lastAxisValue = mutableMapOf() + private val supportedAxes = listOf( + SupportedAxis(MotionEvent.AXIS_X), + SupportedAxis(MotionEvent.AXIS_Y, invert = true), + SupportedAxis(MotionEvent.AXIS_Z), + SupportedAxis(MotionEvent.AXIS_RZ, invert = true), + SupportedAxis(MotionEvent.AXIS_HAT_X), + SupportedAxis(MotionEvent.AXIS_HAT_Y, invert = true), + SupportedAxis(MotionEvent.AXIS_LTRIGGER), + SupportedAxis(MotionEvent.AXIS_RTRIGGER), + ) + + fun onKeyEvent(keyEvent: KeyEvent, channel: MethodChannel): Boolean { + val arguments = mapOf( + "gamepadId" to keyEvent.getDeviceId().toString(), + "time" to keyEvent.getEventTime(), + "type" to "button", + "key" to KeyEvent.keyCodeToString(keyEvent.getKeyCode()), + "value" to keyEvent.getAction().toDouble() + ) + channel.invokeMethod("onGamepadEvent", arguments) + return true + } + + fun onMotionEvent(motionEvent: MotionEvent, channel: MethodChannel): Boolean { + supportedAxes.forEach { + reportAxis(motionEvent, channel, it.axisId, it.invert) + } + return true + } + + private fun reportAxis(motionEvent: MotionEvent, channel: MethodChannel, axis: Int, invert: Boolean = false): Boolean { + val multiplier = if (invert) -1 else 1 + val value = motionEvent.getAxisValue(axis) * multiplier + + // No-op if threshold is not met + val lastValue = lastAxisValue[axis] + if (lastValue is Float) { + if (abs(value - lastValue) < axisEpisilon) { + return true; + } + } + // Update last value + lastAxisValue[axis] = value + + val arguments = mapOf( + "gamepadId" to motionEvent.getDeviceId().toString(), + "time" to motionEvent.getEventTime(), + "type" to "analog", + "key" to MotionEvent.axisToString(axis), + "value" to value, + ) + channel.invokeMethod("onGamepadEvent", arguments) + return true + } +} \ No newline at end of file diff --git a/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/GamepadsAndroidPlugin.kt b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/GamepadsAndroidPlugin.kt new file mode 100644 index 0000000..56ef705 --- /dev/null +++ b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/GamepadsAndroidPlugin.kt @@ -0,0 +1,93 @@ +package org.flame_engine.gamepads_android + +import androidx.annotation.NonNull +import android.app.Activity +import android.content.Context +import android.hardware.input.InputManager +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import kotlin.concurrent.thread + +class GamepadsAndroidPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { + companion object { + private const val TAG = "GamepadsAndroidPlugin" + } + private lateinit var channel : MethodChannel + private lateinit var devices : DeviceListener + private lateinit var events : EventListener + + private fun listGamepads(): List> { + return devices.getDevices().map { device -> + mapOf( + "id" to device.key.toString(), + "name" to device.value.name + ) + } + } + + // FlutterPlugin + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "xyz.luan/gamepads") + channel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + if (call.method == "listGamepads") { + result.success(listGamepads()) + } else { + result.notImplemented() + } + } + + // Activity Aware + override fun onAttachedToActivity(activityPluginBinding: ActivityPluginBinding) { + onAttachedToActivityShared(activityPluginBinding.activity) + } + + fun onAttachedToActivityShared(activity: Activity) { + val compatibleActivity = activity as GamepadsCompatibleActivity + devices = DeviceListener { compatibleActivity.isGamepadsInputDevice(it) } + events = EventListener() + compatibleActivity.registerInputDeviceListener(devices, handler = null) + compatibleActivity.registerKeyEventHandler { event -> + if (devices.containsKey(event.deviceId)) { + events.onKeyEvent(event, channel) + } else { + false + } + } + compatibleActivity.registerMotionEventHandler { event -> + if (devices.containsKey(event.deviceId)) { + events.onMotionEvent(event, channel) + } else { + false + } + } + } + + override fun onDetachedFromActivity() { + // No-op + } + + override fun onDetachedFromActivityForConfigChanges() { + // No-op + } + + override fun onReattachedToActivityForConfigChanges(activityPluginBinding: ActivityPluginBinding) { + onAttachedToActivityShared(activityPluginBinding.activity) + } +} diff --git a/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/GamepadsCompatibleActivity.kt b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/GamepadsCompatibleActivity.kt new file mode 100644 index 0000000..08cd640 --- /dev/null +++ b/packages/gamepads_android/android/src/main/kotlin/org/flame_engine/gamepads_android/GamepadsCompatibleActivity.kt @@ -0,0 +1,18 @@ +package org.flame_engine.gamepads_android + +import android.hardware.input.InputManager +import android.os.Handler +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +interface GamepadsCompatibleActivity { + fun isGamepadsInputDevice(device: InputDevice): Boolean { + return device.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD + || device.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + } + + fun registerInputDeviceListener(listener: InputManager.InputDeviceListener, handler: Handler?) + fun registerKeyEventHandler(handler: (KeyEvent) -> Boolean) + fun registerMotionEventHandler(handler: (MotionEvent) -> Boolean) +} \ No newline at end of file diff --git a/packages/gamepads_android/gradle.properties b/packages/gamepads_android/gradle.properties new file mode 100644 index 0000000..8d2d1b5 --- /dev/null +++ b/packages/gamepads_android/gradle.properties @@ -0,0 +1 @@ +android.prefabVersion=2.0.0 \ No newline at end of file diff --git a/packages/gamepads_android/pubspec.yaml b/packages/gamepads_android/pubspec.yaml new file mode 100644 index 0000000..285f830 --- /dev/null +++ b/packages/gamepads_android/pubspec.yaml @@ -0,0 +1,26 @@ +name: gamepads_android +description: Android implementation of gamepads, a Flutter plugin to handle gamepad input across multiple platforms. +version: 0.1.1 +homepage: https://github.com/flame-engine/gamepads +repository: https://github.com/flame-engine/gamepads/tree/main/packages/gamepads_android + +flutter: + plugin: + platforms: + android: + package: org.flame_engine.gamepads_android + pluginClass: GamepadsAndroidPlugin + +environment: + sdk: '>=2.19.0 <3.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + gamepads_platform_interface: ^0.1.1 + +dev_dependencies: + flame_lint: ^0.2.0 + flutter_test: + sdk: flutter diff --git a/packages/gamepads_ios/pubspec.yaml b/packages/gamepads_ios/pubspec.yaml index 39ae644..7595bef 100644 --- a/packages/gamepads_ios/pubspec.yaml +++ b/packages/gamepads_ios/pubspec.yaml @@ -12,7 +12,7 @@ flutter: pluginClass: GamepadsIosPlugin environment: - sdk: '>=2.19.0 <4.0.0' + sdk: '>=2.19.0 <3.0.0' flutter: '>=3.3.0' dependencies: