Skip to content

Commit fbb0cf6

Browse files
authored
Merge pull request #145 from winnerspiros/android-s-pen-optimization-482001571077316104
Optimize Android Input Latency and Native S Pen Support
2 parents 126ce93 + db7034d commit fbb0cf6

3 files changed

Lines changed: 180 additions & 3 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using Android.Views;
6+
using osu.Framework.Bindables;
7+
using osu.Framework.Input.Handlers;
8+
using osu.Framework.Input.Handlers.Tablet;
9+
using osu.Framework.Input.StateChanges;
10+
using osu.Framework.Platform;
11+
using osu.Framework.Logging;
12+
using osuTK;
13+
using osuTK.Input;
14+
15+
namespace osu.Android.Input
16+
{
17+
public class AndroidStylusHandler : InputHandler, ITabletHandler
18+
{
19+
public override string Description => "S Pen / Stylus";
20+
21+
public Bindable<Vector2> AreaOffset { get; } = new Bindable<Vector2>();
22+
public Bindable<Vector2> AreaSize { get; } = new Bindable<Vector2>();
23+
public Bindable<Vector2> OutputAreaSize { get; } = new Bindable<Vector2>();
24+
public Bindable<Vector2> OutputAreaOffset { get; } = new Bindable<Vector2>();
25+
public IBindable<TabletInfo?> Tablet => tablet;
26+
public Bindable<float> Rotation { get; } = new Bindable<float>();
27+
public BindableFloat PressureThreshold { get; } = new BindableFloat(0.05f)
28+
{
29+
MinValue = 0f,
30+
MaxValue = 1f,
31+
Precision = 0.005f,
32+
};
33+
34+
private readonly Bindable<TabletInfo?> tablet = new Bindable<TabletInfo?>();
35+
36+
public override bool IsActive => Enabled.Value;
37+
38+
private bool lastLeftDown;
39+
private bool lastRightDown;
40+
private bool firstEventReceived;
41+
42+
public AndroidStylusHandler()
43+
{
44+
Enabled.Default = true;
45+
Enabled.Value = true;
46+
}
47+
48+
public override bool Initialize(GameHost host)
49+
{
50+
// Initial tablet info with a sane default. We'll refine this as events arrive.
51+
tablet.Value = new TabletInfo("S Pen", new Vector2(2000, 1000));
52+
return base.Initialize(host);
53+
}
54+
55+
public void HandleMotionEvent(MotionEvent e)
56+
{
57+
if (!Enabled.Value) return;
58+
59+
if (!firstEventReceived)
60+
{
61+
Logger.Log($"[osu!] S Pen input detected. Source={e.Source}, ToolType={e.GetToolType(0)}", LoggingTarget.Input);
62+
firstEventReceived = true;
63+
}
64+
65+
// Process historical points for maximum accuracy.
66+
for (int i = 0; i < e.HistorySize; i++)
67+
{
68+
handlePointer(e, i);
69+
}
70+
handlePointer(e, -1);
71+
}
72+
73+
private void handlePointer(MotionEvent e, int historyIndex)
74+
{
75+
const int pointer_index = 0;
76+
77+
float x = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex);
78+
float y = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex);
79+
float pressure = historyIndex < 0 ? e.GetPressure(pointer_index) : e.GetHistoricalPressure(pointer_index, historyIndex);
80+
81+
// Dynamically update tablet bounds.
82+
if (tablet.Value == null || x > tablet.Value.Size.X || y > tablet.Value.Size.Y)
83+
{
84+
var currentSize = tablet.Value?.Size ?? Vector2.Zero;
85+
var newSize = new Vector2(Math.Max(x, currentSize.X), Math.Max(y, currentSize.Y));
86+
tablet.Value = new TabletInfo("S Pen", newSize);
87+
}
88+
89+
// Report absolute position.
90+
PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(x, y) });
91+
92+
// Map pressure to mouse buttons.
93+
bool isLeftDown = pressure >= PressureThreshold.Value;
94+
if (isLeftDown != lastLeftDown)
95+
{
96+
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Left, isLeftDown));
97+
lastLeftDown = isLeftDown;
98+
}
99+
100+
// Map side button to Right Click.
101+
bool isRightDown = (e.ButtonState & MotionEventButtonState.StylusPrimary) != 0;
102+
if (isRightDown != lastRightDown)
103+
{
104+
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Right, isRightDown));
105+
lastRightDown = isRightDown;
106+
}
107+
}
108+
}
109+
}

osu.Android/OsuGameActivity.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using osu.Android.Input;
12
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
23
// See the LICENCE file in the repository root for full licence text.
34

@@ -18,13 +19,15 @@
1819
using osu.Framework.Android;
1920
using osu.Game.Database;
2021
using osu.Android.Native;
22+
using osu.Framework.Logging;
2123

2224
namespace osu.Android
2325
{
2426
[Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)]
2527
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osz", DataHost = "*", DataMimeType = "*/*")]
2628
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osk", DataHost = "*", DataMimeType = "*/*")]
2729
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osr", DataHost = "*", DataMimeType = "*/*")]
30+
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osr", DataHost = "*", DataMimeType = "application/x-osu-replay")]
2831
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")]
2932
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")]
3033
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")]
@@ -48,6 +51,7 @@ public class OsuGameActivity : AndroidGameActivity, ISurfaceHolderCallback
4851
public ScreenOrientation DefaultOrientation = ScreenOrientation.Unspecified;
4952

5053
public new bool IsTablet { get; private set; }
54+
internal AndroidStylusHandler? StylusHandler;
5155

5256
private OsuGameAndroid? game;
5357

@@ -83,6 +87,19 @@ protected override void OnCreate(Bundle? savedInstanceState)
8387
{
8488
Window.AddFlags(WindowManagerFlags.Fullscreen);
8589
Window.AddFlags(WindowManagerFlags.KeepScreenOn);
90+
91+
// Hide the system pointer icon to prevent double cursors in DeX or with mouse.
92+
if (OperatingSystem.IsAndroidVersionAtLeast(24))
93+
{
94+
try
95+
{
96+
Window.DecorView.PointerIcon = PointerIcon.GetSystemIcon(this, PointerIconType.Null);
97+
}
98+
catch (Exception e)
99+
{
100+
Logger.Log($"[osu!] Failed to hide system pointer icon: {e.Message}", LoggingTarget.Input);
101+
}
102+
}
86103
}
87104

88105
if (WindowManager?.DefaultDisplay != null && Resources?.DisplayMetrics != null)
@@ -106,6 +123,42 @@ protected override void OnCreate(Bundle? savedInstanceState)
106123

107124
protected override void OnNewIntent(Intent? intent) => handleIntent(intent);
108125

126+
public override bool OnTouchEvent(MotionEvent? e)
127+
{
128+
if (e != null && isStylusEvent(e))
129+
{
130+
StylusHandler?.HandleMotionEvent(e);
131+
return true;
132+
}
133+
return base.OnTouchEvent(e);
134+
}
135+
136+
public override bool OnGenericMotionEvent(MotionEvent? e)
137+
{
138+
if (e != null && isStylusEvent(e))
139+
{
140+
StylusHandler?.HandleMotionEvent(e);
141+
return true;
142+
}
143+
return base.OnGenericMotionEvent(e);
144+
}
145+
146+
private bool isStylusEvent(MotionEvent e)
147+
{
148+
// Check source first, as it's the most reliable indicator on some devices.
149+
if ((e.Source & InputSourceType.Stylus) == InputSourceType.Stylus)
150+
return true;
151+
152+
// Check tool type for each pointer.
153+
for (int i = 0; i < e.PointerCount; i++)
154+
{
155+
var toolType = e.GetToolType(i);
156+
if (toolType == MotionEventToolType.Stylus || toolType == MotionEventToolType.Eraser)
157+
return true;
158+
}
159+
return false;
160+
}
161+
109162
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
110163
{
111164
Microsoft.Maui.ApplicationModel.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults);

osu.Android/OsuGameAndroid.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
using Android.OS;
2222
using Android.Views;
2323
using osu.Android.Native;
24+
using osu.Android.Input;
2425
using osu.Framework.Allocation;
2526
using AudioManager = osu.Framework.Audio.AudioManager;
2627
using osu.Framework.Bindables;
@@ -137,6 +138,8 @@ public override Version AssemblyVersion
137138
}
138139
}
139140

141+
private AndroidStylusHandler? stylusHandler;
142+
140143
[BackgroundDependencyLoader]
141144
private void load()
142145
{
@@ -145,7 +148,11 @@ private void load()
145148
LocalConfig.BindWith(OsuSetting.AndroidVulkanProbe, vulkanProbeEnabled);
146149
LocalConfig.BindWith(OsuSetting.AudioOffset, audioOffset);
147150

148-
startVulkanProbe();
151+
stylusHandler = new AndroidStylusHandler();
152+
Host.AvailableInputHandlers.Add(stylusHandler);
153+
gameActivity.StylusHandler = stylusHandler;
154+
155+
startVulkanProbe();
149156

150157
audioRedirector = new OboeAudioRedirector(Audio);
151158

@@ -299,7 +306,8 @@ protected override void LoadComplete()
299306
{
300307
try
301308
{
302-
gameActivity.Window?.DecorView?.RequestUnbufferedDispatch((int)InputSourceType.Touchscreen);
309+
int sources = (int)(InputSourceType.Touchscreen | InputSourceType.Stylus | InputSourceType.Mouse | InputSourceType.Touchpad);
310+
gameActivity.Window?.DecorView?.RequestUnbufferedDispatch(sources);
303311
}
304312
catch (Exception e)
305313
{
@@ -574,11 +582,18 @@ protected override void Dispose(bool isDisposing)
574582
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
575583
protected override void UpdateAfterChildren() => base.UpdateAfterChildren();
576584

585+
public override osu.Game.Overlays.Settings.SettingsSubsection CreateSettingsSubsectionFor(osu.Framework.Input.Handlers.InputHandler handler)
586+
{
587+
if (handler is AndroidStylusHandler stylus)
588+
return new osu.Game.Overlays.Settings.Sections.Input.TabletSettings(stylus);
589+
590+
return base.CreateSettingsSubsectionFor(handler);
591+
}
577592
}
578593

579594
internal class AndroidBatteryInfo : BatteryInfo
580595
{
581596
public override double? ChargeLevel => Microsoft.Maui.Devices.Battery.ChargeLevel;
582597
public override bool OnBattery => Microsoft.Maui.Devices.Battery.PowerSource == global::Microsoft.Maui.Devices.BatteryPowerSource.Battery;
583598
}
584-
}
599+
}

0 commit comments

Comments
 (0)