Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions frontend/drivers/platform_unix.c
Original file line number Diff line number Diff line change
Expand Up @@ -2262,6 +2262,8 @@ static void frontend_unix_init(void *data)
"setScreenOrientation", "(I)V");
GET_METHOD_ID(env, android_app->doVibrate, class,
"doVibrate", "(IIII)V");
GET_METHOD_ID(env, android_app->doVibrateJoypad, class,
"doVibrateJoypad", "(IIII)V");
GET_METHOD_ID(env, android_app->doHapticFeedback, class,
"doHapticFeedback", "(I)V");
GET_METHOD_ID(env, android_app->getUserLanguageString, class,
Expand Down
1 change: 1 addition & 0 deletions frontend/drivers/platform_unix.h
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ struct android_app
jmethodID setScreenOrientation;
jmethodID getUserLanguageString;
jmethodID doVibrate;
jmethodID doVibrateJoypad;
jmethodID doHapticFeedback;

jmethodID isPlayStoreBuild;
Expand Down
64 changes: 50 additions & 14 deletions input/drivers_joypad/android_joypad.c
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Copyright (C) 2011-2017 - Daniel De Matteis
* Copyright (C) 2012-2015 - Michael Lelli
* Copyright (C) 2013-2014 - Steven Crowe
* Copyright (C) 2026 - Adam "TideGear" Milecki
*
* RetroArch is free software: you can redistribute it and/or modify it under the terms * of the GNU General Public License as published by the Free Software Found-
* ation, either version 3 of the License, or (at your option) any later version.
Expand Down Expand Up @@ -170,33 +171,68 @@ static void android_input_set_rumble_internal(
int8_t id,
enum retro_rumble_effect effect)
{
JNIEnv *env = (JNIEnv*)jni_thread_getenv();
uint16_t new_strength = 0;
JNIEnv *env = (JNIEnv*)jni_thread_getenv();

if (!env)
return;

/* Update the per-channel state independently. */
if (effect == RETRO_RUMBLE_STRONG)
{
new_strength = strength | *last_strength_weak;
*last_strength_strong = strength;
}
else if (effect == RETRO_RUMBLE_WEAK)
{
new_strength = strength | *last_strength_strong;
*last_strength_weak = strength;

/* Controller dual-motor path (Android 12+):
*
* doVibrateJoypad receives both channels separately so the Java side
* can drive each controller motor independently via VibratorManager.
*
* The old code OR-merged strong | weak into a single amplitude before
* calling doVibrate, which destroyed motor separation and made the
* weak (small/high-freq) and strong (large/low-freq) motors feel
* identical. */
if (id >= 0 && g_android->doVibrateJoypad)
{
/* Normalize the 0–65535 libretro range into 0–255 Android amplitude.
* Storing first avoids the JNI zero-value bug noted below. */
int strong_final = (int)((255.0f / 65535.0f) * (float)*last_strength_strong);
int weak_final = (int)((255.0f / 65535.0f) * (float)*last_strength_weak);

/* Pack both 0–255 amplitudes into the uint16_t for change detection:
* high byte = strong amplitude, low byte = weak amplitude. */
uint16_t new_combined = (uint16_t)((strong_final << 8) | weak_final);

if (new_combined != *last_strength)
{
CALL_VOID_METHOD_PARAM(env, g_android->activity->clazz,
g_android->doVibrateJoypad, (jint)id,
(jint)strong_final, (jint)weak_final, (jint)0);

*last_strength = new_combined;
}
return;
}

if (new_strength != *last_strength)
/* Legacy single-vibrator fallback:
* - Device vibration path (id == -1)
* - Android < 12 builds where doVibrateJoypad is unavailable
* OR-merge preserves the original behavior for these cases so that
* controllers still rumble rather than going completely silent. */
{
/* trying to send this value as a JNI param without
* storing it first was causing 0 to be seen on the other side ?? */
int strength_final = (255.0f / 65535.0f) * (float)new_strength;
uint16_t new_strength = *last_strength_strong | *last_strength_weak;

CALL_VOID_METHOD_PARAM(env, g_android->activity->clazz,
g_android->doVibrate, (jint)id, (jint)RETRO_RUMBLE_STRONG, (jint)strength_final, (jint)0);
if (new_strength != *last_strength)
{
/* trying to send this value as a JNI param without
* storing it first was causing 0 to be seen on the other side ?? */
int strength_final = (255.0f / 65535.0f) * (float)new_strength;

CALL_VOID_METHOD_PARAM(env, g_android->activity->clazz,
g_android->doVibrate, (jint)id, (jint)RETRO_RUMBLE_STRONG,
(jint)strength_final, (jint)0);

*last_strength = new_strength;
*last_strength = new_strength;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
/* RetroArch - A frontend for libretro.
* Copyright (C) 2026 - Adam "TideGear" Milecki
*
* RetroArch is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation,
* either version 3 of the License, or (at your option) any later version.
*
* RetroArch is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
* PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with RetroArch.
* If not, see <http://www.gnu.org/licenses/>.
*/
package com.retroarch.browser.retroactivity;

import com.retroarch.BuildConfig;
Expand Down Expand Up @@ -27,8 +41,10 @@
import android.os.BatteryManager;
import android.os.Build;
import android.os.PowerManager;
import android.os.CombinedVibration;
import android.os.Vibrator;
import android.os.VibrationEffect;
import android.os.VibratorManager;
import android.util.Log;


Expand Down Expand Up @@ -149,6 +165,118 @@ public void doVibrate(int id, int effect, int strength, int oneShot)
}
}

/**
* Vibrates a controller's motors independently for dual-rumble gamepads.
*
* On Android 12+ (API 31) this uses VibratorManager to address each
* controller motor separately, preserving the RETRO_RUMBLE_STRONG /
* RETRO_RUMBLE_WEAK distinction that the legacy single-vibrator path lost
* by OR-merging both channels into one amplitude value.
*
* Falls back to doVibrate (single-vibrator) on Android < 12 or when the
* controller does not expose multiple vibrators.
*
* @param id InputDevice ID of the controller
* @param strongStrength Large / low-frequency motor amplitude (0–255)
* @param weakStrength Small / high-frequency motor amplitude (0–255)
* @param unused Reserved; always pass 0
*/
public void doVibrateJoypad(int id, int strongStrength, int weakStrength, int unused)
{
/* Android 12+ (API 31): attempt the multi-vibrator path. */
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
if (doVibrateJoypadApi31(id, strongStrength, weakStrength))
return;
}

/* Fallback for Android < 12 or when VibratorManager cannot be used:
* drive the single vibrator with the stronger channel value so the
* controller still rumbles rather than staying silent. */
int fallbackStrength = Math.max(strongStrength, weakStrength);
doVibrate(id, RETRO_RUMBLE_STRONG, fallbackStrength, 0);
}

/**
* Inner implementation of doVibrateJoypad for Android 12+ (API 31).
*
* Uses InputDevice.getVibratorManager() to enumerate the controller's
* vibrator IDs and drive them independently via CombinedVibration so each
* motor receives its own amplitude.
*
* The mapping assumes vibratorIds[0] is the large / low-frequency
* (strong) motor and vibratorIds[1] is the small / high-frequency (weak)
* motor. This ordering has been observed consistently for DualShock 4,
* DualSense, and Xbox controllers on Android 12+, but is NOT guaranteed
* by the Android API — treat this as a best-effort heuristic and code
* defensively.
*
* @return true if vibration was handled, false to trigger fallback
*/
@TargetApi(Build.VERSION_CODES.S)
private boolean doVibrateJoypadApi31(int id, int strongStrength, int weakStrength)
{
InputDevice dev = InputDevice.getDevice(id);
if (dev == null)
return false;

VibratorManager vm = dev.getVibratorManager();
int[] vibratorIds = vm.getVibratorIds();

if (vibratorIds.length == 0)
return false;

if (vibratorIds.length == 1)
{
/* Single-motor controller: use the stronger channel value so the
* controller still rumbles rather than going silent. */
int singleStrength = Math.max(strongStrength, weakStrength);
Vibrator singleVibrator = vm.getVibrator(vibratorIds[0]);

if (singleStrength == 0)
{
singleVibrator.cancel();
return true;
}

long[] timings = {0, 1000};
int[] amps = {0, singleStrength};
singleVibrator.vibrate(
VibrationEffect.createWaveform(timings, amps, 0),
new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build());
return true;
}

/* Dual-motor (or more) path: cancel cleanly when both are zero. */
if (strongStrength == 0 && weakStrength == 0)
{
vm.cancel();
return true;
}

/* Drive both motors with independent looping waveforms. */
long[] timings = {0, 1000};
VibrationEffect strongEffect = VibrationEffect.createWaveform(
timings, new int[]{0, strongStrength}, 0);
VibrationEffect weakEffect = VibrationEffect.createWaveform(
timings, new int[]{0, weakStrength}, 0);

CombinedVibration combined = CombinedVibration.startParallel()
.addVibrator(vibratorIds[0], strongEffect)
.addVibrator(vibratorIds[1], weakEffect)
.combine();

vm.vibrate(combined);

Log.i("RetroActivity", "doVibrateJoypad id=" + id
+ " strong=" + strongStrength + " weak=" + weakStrength
+ " vibrators=" + vibratorIds.length);
return true;
}

public void doHapticFeedback(int effect)
{
getWindow().getDecorView().performHapticFeedback(effect,
Expand Down
2 changes: 1 addition & 1 deletion pkg/android/phoenix/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ allprojects {
apply plugin: 'com.android.application'

android {
compileSdkVersion 30
compileSdkVersion 31
buildToolsVersion "30.0.3"
ndkVersion "22.0.7026061"

Expand Down
Loading