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