Skip to content
This repository has been archived by the owner on Jun 23, 2023. It is now read-only.

Android support #1

Merged
merged 9 commits into from
Nov 26, 2019
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

A platform library for listening to hardware gamepads (game controllers) from Flutter.

**Currently supports iOS only. Android coming soon!**

## Features

* `FlutterGamepad.gamepads()` returns info about all currently connected gamepads.
* `FlutterGamepad.eventStream` reports gamepad events.
* `FlutterGamepad.eventStream` reports gamepad events. (See example)
* Fractional button values, such as those reported by the left and right trigger buttons on most gamepads, are supported.
* Supports iOS 13+, as well as older versions of iOS.
* Supports Android.
* Supports multiple simultaneous gamepads. Events are tagged with an ID you can tell gamepads apart by.

## Caveats

* On Android, the B button seems to trigger a "back" action. You'll want to have a `WillPopScope` on your game scaffold to prevent this.
* On Android, "disconnect" events are never fired. "Connect" events are fired initially when listening to the stream, and thereafter on the first input of any new gamepads. This differs from the iOS behavior, where a "connect" event is sent when the connection is established, even if no buttons have been pressed.

## Example

```dart
Expand Down
27 changes: 26 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
compileSdkVersion 28
compileSdkVersion 29

sourceSets {
main.java.srcDirs += 'src/main/kotlin'
Expand All @@ -42,3 +42,28 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

afterEvaluate {
def containsEmbeddingDependencies = false
for (def configuration : configurations.all) {
for (def dependency : configuration.dependencies) {
if (dependency.group == 'io.flutter' &&
dependency.name.startsWith('flutter_embedding') &&
dependency.isTransitive())
{
containsEmbeddingDependencies = true
break
}
}
}
if (!containsEmbeddingDependencies) {
android {
dependencies {
def lifecycle_version = "1.1.1"
compileOnly "android.arch.lifecycle:runtime:$lifecycle_version"
compileOnly "android.arch.lifecycle:common:$lifecycle_version"
compileOnly "android.arch.lifecycle:common-java8:$lifecycle_version"
}
}
}
}
55 changes: 55 additions & 0 deletions android/src/main/kotlin/com/example/flutter_gamepad/Button.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.example.flutter_gamepad

import android.view.KeyEvent

/**
* The buttons on a gamepad, in flutter_gamepad order. This order matches the one on the Dart side.
*/
enum class Button {
A,
B,
X,
Y,
DpadUp,
DpadDown,
DpadLeft,
DpadRight,
Menu,
Options,
LeftThumbstickButton,
RightThumbstickButton,
LeftShoulder,
RightShoulder,
LeftTrigger,
RightTrigger,
}

/**
* The thumbsticks on a gamepad, in flutter_gamepad order. This order matches the one on the Dart side.
*/
enum class Thumbstick {
Left,
Right,
}

/**
* A map from Android key-codes to the flutter_gamepad Button enum.
*/
val buttonMap = hashMapOf(
KeyEvent.KEYCODE_BUTTON_A to Button.A,
KeyEvent.KEYCODE_BUTTON_B to Button.B,
KeyEvent.KEYCODE_BUTTON_X to Button.X,
KeyEvent.KEYCODE_BUTTON_Y to Button.Y,
KeyEvent.KEYCODE_DPAD_UP to Button.DpadUp,
KeyEvent.KEYCODE_DPAD_DOWN to Button.DpadDown,
KeyEvent.KEYCODE_DPAD_LEFT to Button.DpadLeft,
KeyEvent.KEYCODE_DPAD_RIGHT to Button.DpadRight,
KeyEvent.KEYCODE_BUTTON_START to Button.Menu,
KeyEvent.KEYCODE_BUTTON_SELECT to Button.Options,
KeyEvent.KEYCODE_BUTTON_THUMBL to Button.LeftThumbstickButton,
KeyEvent.KEYCODE_BUTTON_THUMBR to Button.RightThumbstickButton,
KeyEvent.KEYCODE_BUTTON_L1 to Button.LeftShoulder,
KeyEvent.KEYCODE_BUTTON_R1 to Button.RightShoulder,
KeyEvent.KEYCODE_BUTTON_L2 to Button.LeftTrigger,
KeyEvent.KEYCODE_BUTTON_R2 to Button.RightTrigger
)
Original file line number Diff line number Diff line change
@@ -1,25 +1,93 @@
package com.example.flutter_gamepad

import android.view.KeyEvent
import android.view.MotionEvent
import io.flutter.embedding.android.AndroidKeyProcessor
import io.flutter.embedding.android.AndroidTouchProcessor
import io.flutter.view.FlutterView
import io.flutter.embedding.engine.renderer.FlutterRenderer
import io.flutter.embedding.engine.systemchannels.KeyEventChannel
import io.flutter.plugin.common.EventChannel
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 io.flutter.plugin.common.PluginRegistry.Registrar
import io.flutter.plugin.editing.TextInputPlugin
import java.lang.reflect.Field

class FlutterGamepadPlugin: MethodCallHandler {
companion object {
@JvmStatic
fun registerWith(registrar: Registrar) {
val channel = MethodChannel(registrar.messenger(), "flutter_gamepad")
channel.setMethodCallHandler(FlutterGamepadPlugin())
/**
* An extension of Flutter's AndroidTouchProcessor that delegates MotionEvents to the GamepadStreamHandler.
* (On Android, thumbstick events from a gamepad are a kind of MotionEvent.)
*/
class GamepadAndroidTouchProcessor(renderer: FlutterRenderer) : AndroidTouchProcessor(renderer) {
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
return GamepadStreamHandler.processMotionEvent(event) || super.onGenericMotionEvent(event)
}
}

/**
* An extension of Flutter's AndroidKeyProcessor that delegates KeyEvents to the GamepadStreamHandler.
* (On Android, button events from a gamepad are a kind of KeyEvent.)
*/
class GamepadAndroidKeyProcessor(keyEventChannel: KeyEventChannel, textInputPlugin: TextInputPlugin) : AndroidKeyProcessor(keyEventChannel, textInputPlugin) {
override fun onKeyDown(keyEvent: KeyEvent) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, but why/how do gamepads trigger key events?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, on Android a gamepad button press is a kind of KeyEvent. :)

(And a gamepad thumbstick movement is a kind of MotionEvent, just like a mouse or pointer movement.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added comments explaining this

val handled = GamepadStreamHandler.processKeyDownEvent(keyEvent)
if (!handled) {
super.onKeyDown(keyEvent)
}
}

override fun onKeyUp(keyEvent: KeyEvent) {
val handled = GamepadStreamHandler.processKeyUpEvent(keyEvent)
if (!handled) {
super.onKeyDown(keyEvent)
}
}
}

/**
* The flutter_gamepad plugin class that is registered with the framework.
*/
class FlutterGamepadPlugin : MethodCallHandler {
companion object {
@JvmStatic
fun registerWith(registrar: Registrar) {
val methodChannel = MethodChannel(registrar.messenger(), "com.rainway.flutter_gamepad/methods")
methodChannel.setMethodCallHandler(FlutterGamepadPlugin())
val eventChannel = EventChannel(registrar.messenger(), "com.rainway.flutter_gamepad/events")
eventChannel.setStreamHandler(GamepadStreamHandler)

val view: FlutterView = registrar.view()
fun viewField(name: String): Field {
val field = FlutterView::class.java.getDeclaredField(name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a try/catch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below: if this process goes wrong for someone after a Flutter-internal change, we can't really save anything and probably want to let that error propagate so they can tell us it broke.

field.isAccessible = true
return field
}

// Hack: swap in a new AndroidTouchProcessor.
val touchProcessorField = viewField("androidTouchProcessor")
val rendererField = viewField("flutterRenderer")
val renderer = rendererField.get(view) as FlutterRenderer
val touchProcessor = GamepadAndroidTouchProcessor(renderer)
touchProcessorField.set(view, touchProcessor)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Hack" comment seems to suggest that this solution is tenuous in that it could possibly break if Flutter changes something about how it is implemented. For example if the "androidTouchProcessor" field changes names perhaps? If something like that were to happen would this code throw an uncaught exception? If so, should we try to catch the exception here and deal with it gracefully?

Copy link
Contributor Author

@lynn lynn Nov 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — it's relying on the presence of a (private!) field with that name in a class in the Flutter engine, which could change in a future version.

This patching step is crucial to the working of the plugin: if it goes wrong, then the plug-in can't possibly do its job (because we will have no access to key/motion events). In a sense, crashing feels like the “right” thing to do, then: users of the plug-in will make an issue here on GitHub, and it will be up to us to fix and accommodate the new Flutter version.

If we preempt such a failure and Log.warning("uh-oh, blah blah blah, here's a stacktrace") and do nothing else, we will just be making the failure a little less obvious.

It would be a different story if the Flutter version wasn't “baked into” an app. That is: if the trick works (for the user) in a given build of an app, then it won't suddenly stop working. So users of this plug-in (i.e. Flutter app developers) don't have to worry about this hack suddenly breaking on them if it works once for them.

Copy link
Member

@andrewmd5 andrewmd5 Nov 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this what RawKeyboard is for in combination with InputDevice using a SOURCE_GAMEPAD so you don't need to call private methods ,and rather handle the Raw key events?

https://api.flutter.dev/flutter/services/RawKeyEventDataAndroid-class.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you (as a Flutter app developer) can use a RawKeyboard Flutter widget to get gamepad button events.

However, at the time of writing, that works only on Android, and only buttons are supported: the motion events from thumbsticks are discarded by AndroidTouchProcessor. (The events also don't seem to have a deviceId field, only vendorId and productId, so theoretically you couldn't tell two identical Dualshocks connected to the device apart. This is kind of a nitpick.)

If we want to offer a plug-in that has the same API on both iOS and Android, i.e. if we want to put button events onto the flutter_gamepad event stream, then we need the trick, because Flutter widgets are immaterial to us at the plug-in level. So RawKeyboard's existence does not help us much.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to this PR, but just had the thought that it might be a good idea to specify on this https://pub.dev/packages/flutter_gamepad that it is tested and works with specific versions of Flutter and that it is potentially not compatible with versions that haven't been tested.

Also I'm curious, if the flutter_gamepad plugin did cause a crash due to incompatibility with the version of Flutter that the Flutter app developer is using it with, would it be obvious to the developer why/where it crashed (assuming the developer has no background in platform specific development so doing something like running the app through XCode is too foreign for them to try)? In other words, if it were to crash after said app developer did a "flutter run" on their app, would they be able to tell that it is the gamepad_plugin that is malfunctioning?


// Hack: swap in a new AndroidKeyProcessor.
val keyProcessorField = viewField("androidKeyProcessor")
val keyEventChannelField = viewField("keyEventChannel")
val textInputPluginField = viewField("mTextInputPlugin")
val keyEventChannel = keyEventChannelField.get(view) as KeyEventChannel
val textInputPlugin = textInputPluginField.get(view) as TextInputPlugin
val keyProcessor = GamepadAndroidKeyProcessor(keyEventChannel, textInputPlugin)
keyProcessorField.set(view, keyProcessor)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here regarding "Hack".

}
}
}

override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "getPlatformVersion") {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
} else {
result.notImplemented()
override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "gamepads") {
result.success(allGamepadInfoDictionaries())
} else {
result.notImplemented()
}
}
}
}
Loading