Bevy- touch input on Android

Bevy version: 0.16

Update

Simple fix- use updated android activity.

To do so, specify the android_activity dependency in your Cargo.toml file:

[patch.crates-io]
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "a8948332c7c551303d32eb26a59d0abd676e47a5" }

In app/build.gradle update the android-activity dependency from:

implementation libs.games.activity

to:

implementation "androidx.games:games-activity:4.0.0"

That's it! All of the stuff below is my previous fix that is not required now.

Content

My game didn't received any touch input on Android, even though it was working just fine on desktop and iOS. But I was able to write a plugin that detects touch input in GameActivity and sends it to Bevy.

Using in Bevy

Add plugin:

...
app.add_plugins(bevy_android_input::AndroidInputPlugin);
...

Also, it will require adding android depencency and entry binding that will be used by GameActivity.

Android only dependency:

[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"

Function that will be used by java GameActivity:

#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_org_mycompany_idlerpg_MainActivity_setTouchState(
    _env: jni::sys::JNIEnv,
    _class: jni::objects::JClass,
    id: jni::sys::jint,
    x: jni::sys::jfloat,
    y: jni::sys::jfloat,
    state: jni::sys::jint,
) {
    bevy_android_input::queue_input_update(id as i32, x as f32, y as f32, state as i32);
}

Notice name of the function, it should follow the pattern: Java_ + ID of the application with _ instead of dots + _ + activity class + _ + method name. So, since I have org.mycompany.idlerpg id with MainActivity class and setTouchState method, it should be named Java_org_mycompany_idlerpg_MainActivity_setTouchState.

In the java MainActivity class, you need to add the following code:

public class MainActivity extends GameActivity {
...
    public native void setTouchState(int id, float x, float y, int state);

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        int pointerIndex = event.getActionIndex();
        int pointerId = event.getPointerId(pointerIndex);
        // Yes, you can detect touch events here
        float x = event.getX();
        float y = event.getY();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
                setTouchState(pointerId, x,y, 0);
                break;
            case MotionEvent.ACTION_MOVE:
                for (int i = 0; i < event.getPointerCount(); i++) {
                    int id = event.getPointerId(i);
                    float px = event.getX(i);
                    float py = event.getY(i);
                    setTouchState(id, px,py, 1);
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                setTouchState(pointerId, x,y, 2);
                break;
        }

        return super.onTouchEvent(event);
    }
...

}

Code

In this example there are external dependencies besides Bevy, here is the Cargo.toml file of the plugin:

[package]
name = "bevy_android_input"
version = "0.1.0"
edition = "2021"
authors = ["Piotr Siuszko <siuszko@zoho.com>"]
license = "MIT OR Apache-2.0"
keywords = ["bevy", "android", "plugin"]

[dependencies]
once_cell = "1"
crossbeam = "0.8"
bevy = {version = "0.16", default_features = false, features = [
"bevy_ui","bevy_picking","bevy_window"]}
bevy_reflect = "0.16"

Plugin src/lib.rs

//! Android input handling plugin for Bevy applications.
//!
//! This module provides functionality to bridge Android touch input events
//! with Bevy's input system, handling coordinate transformations and camera
//! viewport adjustments.

use bevy::{
    app::{First, Plugin, Startup, Update},
    ecs::{
        entity::{ContainsEntity, Entity},
        event::EventWriter,
        query::{Changed, With},
        resource::Resource,
        schedule::IntoScheduleConfigs,
        system::{Query, Res, ResMut, Single},
    },
    input::touch::{TouchInput, TouchPhase},
    math::Vec2,
    prelude::ReflectResource,
    reflect::Reflect,
    render::camera::{Camera, NormalizedRenderTarget},
    ui::IsDefaultUiCamera,
    window::PrimaryWindow,
};
use crossbeam::queue::SegQueue;
use once_cell::sync::Lazy;

/// Thread-safe queue for storing Android touch input events.
/// This allows the Android native layer to push events that will be
/// processed by the Bevy systems.
static INPUT_QUEUE: Lazy<SegQueue<AndroidTouchData>> = Lazy::new(|| SegQueue::new());

/// Bevy plugin that integrates Android touch input handling.
///
/// This plugin sets up the necessary systems to:
/// - Track camera information for coordinate transformations
/// - Process queued Android touch events
/// - Convert them to Bevy input events
pub struct AndroidInputPlugin;

impl Plugin for AndroidInputPlugin {
    fn build(&self, app: &mut bevy::app::App) {
        app.init_resource::<AndroidCameraInformation>()
            .register_type::<AndroidTouchData>()
            .register_type::<AndroidCameraInformation>()
            .add_systems(Startup, AndroidCameraInformation::read_info)
            .add_systems(
                Update,
                AndroidCameraInformation::read_info
                    .run_if(AndroidCameraInformation::should_rebuild),
            )
            .add_systems(
                First,
                (AndroidCameraInformation::pass_android_input_events)
                    .in_set(bevy::ecs::event::EventUpdates),
            );
    }
}

/// Raw touch data received from the Android platform.
///
/// This struct represents a single touch event with its basic properties
/// before being converted to Bevy's touch input format.
#[derive(Debug, Clone, Reflect, Copy)]
pub struct AndroidTouchData {
    /// Unique identifier for this touch point
    id: i32,
    /// Current phase of the touch (started, moved, ended, canceled)
    phase: TouchPhase,
    /// X coordinate
    x: f32,
    /// Y coordinate
    y: f32,
}

impl Default for AndroidTouchData {
    fn default() -> Self {
        Self {
            id: 0,
            phase: TouchPhase::Canceled,
            x: 0.0,
            y: 0.0,
        }
    }
}

/// Resource containing camera information needed for input coordinate transformation.
///
/// This resource tracks the camera's viewport offset and scaling factor
/// to properly convert Android screen coordinates to Bevy world coordinates.
#[derive(Debug, Clone, Copy, Resource, Reflect)]
#[reflect(Resource)]
pub(crate) struct AndroidCameraInformation {
    /// Viewport offset from the camera's physical viewport rectangle
    pub offset: Vec2,
    /// Scaling factor for input coordinates based on camera target scaling
    pub input_multiplier: f32,
    /// Reference to the primary window
    pub window: Entity,
}

impl AndroidCameraInformation {
    /// Determines if camera information should be rebuilt.
    pub fn should_rebuild(camera: Query<(), (Changed<Camera>, With<IsDefaultUiCamera>)>) -> bool {
        camera.iter().next().is_some()
    }

    /// Reads and updates camera information from the current camera and window state.
    pub fn read_info(
        mut res: ResMut<Self>,
        camera: Option<Single<&Camera, With<IsDefaultUiCamera>>>,
        window: Option<Single<Entity, With<PrimaryWindow>>>,
    ) {
        let Some(camera) = camera else {
            return;
        };
        let Some(window) = window else {
            return;
        };
        let Some(NormalizedRenderTarget::Window(window_ref)) =
            camera.target.normalize(Some(*window))
        else {
            res.offset = Vec2::ZERO;
            res.input_multiplier = 1.0;
            res.window = Entity::PLACEHOLDER;
            return;
        };
        res.window = window_ref.entity();
        let offset = camera.physical_viewport_rect().unwrap_or_default().min;
        res.offset = Vec2::new(offset.x as f32, offset.y as f32);
        res.input_multiplier = camera.target_scaling_factor().unwrap_or(1.0);
    }

    /// System that processes queued Android input events and converts them to Bevy events.
    ///
    /// This system runs during the First schedule to ensure input events are available
    /// for other systems in the same frame. It pops events from the INPUT_QUEUE and
    /// sends them as both TouchInput and WindowEvent.
    pub fn pass_android_input_events(
        info: Res<Self>,
        mut window_writer: EventWriter<bevy::window::WindowEvent>,
        mut touch_writer: EventWriter<TouchInput>,
    ) {
        while let Some(event) = INPUT_QUEUE.pop() {
            let touch_input = info.to_bevy_input(event);
            touch_writer.write(touch_input);
            window_writer.write(touch_input.into());
        }
    }

    /// Converts raw Android touch data into a Bevy TouchInput event.
    pub(crate) fn to_bevy_input(&self, touch: AndroidTouchData) -> TouchInput {
        TouchInput {
            window: self.window.clone(),
            phase: touch.phase,
            position: self.adjust_pos(touch.x, touch.y),
            id: touch.id as u64,
            force: None,
        }
    }

    /// Adjusts raw screen coordinates to account for camera viewport and scaling.
    #[inline]
    pub fn adjust_pos(&self, x: f32, y: f32) -> Vec2 {
        Vec2::new(x, y) / self.input_multiplier + self.offset
    }
}

impl Default for AndroidCameraInformation {
    fn default() -> Self {
        Self {
            offset: Vec2::ZERO,
            input_multiplier: 1.0,
            window: Entity::PLACEHOLDER,
        }
    }
}

/// Entry point for Android native code to send touch input events.
///
/// This function is called from the Android native layer (typically JNI)
/// to queue touch events for processing by the Bevy input system.
///
/// # Arguments
/// * `id` - Unique identifier for the touch point
/// * `x` - X coordinate in screen space
/// * `y` - Y coordinate in screen space
/// * `state` - Touch state (0=Started, 1=Moved, 2=Ended, other=Canceled)
pub fn queue_input_update(id: i32, x: f32, y: f32, state: i32) {
    let phase = match state {
        0 => TouchPhase::Started,
        1 => TouchPhase::Moved,
        2 => TouchPhase::Ended,
        _ => TouchPhase::Canceled,
    };
    INPUT_QUEUE.push(AndroidTouchData { phase, id, x, y });
}