55using System . Runtime . CompilerServices ;
66using Android . Views ;
77using osu . Framework . Bindables ;
8+ using osu . Framework . Input ;
89using osu . Framework . Input . Handlers ;
910using osu . Framework . Input . Handlers . Tablet ;
1011using osu . Framework . Input . StateChanges ;
@@ -40,6 +41,16 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
4041 private readonly Bindable < TabletInfo ? > tablet = new Bindable < TabletInfo ? > ( ) ;
4142
4243 private bool lastLeftDown ;
44+ private bool lastTouchActive ;
45+
46+ /// <summary>
47+ /// Mirrored from <see cref="osu.Game.Configuration.OsuSetting.AndroidStylusAsTouch"/>.
48+ /// When true, stylus events are enqueued as <see cref="TouchInput"/> (TouchSource.Touch1)
49+ /// instead of <see cref="MousePositionAbsoluteInput"/> + <see cref="MouseButtonInput"/>.
50+ /// Held as a volatile field so the OS dispatch thread can read it without
51+ /// crossing the managed-config bindable lock on every motion event.
52+ /// </summary>
53+ public volatile bool TreatAsTouch ;
4354
4455 // Cached area values for hot path (avoids bindable access per event).
4556 private float areaLeft , areaTop , areaWidth , areaHeight ;
@@ -147,11 +158,20 @@ public bool HandleMotionEvent(MotionEvent e)
147158
148159 if ( actionMasked == MotionEventActions . HoverExit || actionMasked == MotionEventActions . Up || actionMasked == MotionEventActions . Cancel )
149160 {
150- if ( lastLeftDown ) { PendingInputs . Enqueue ( new MouseButtonInput ( MouseButton . Left , false ) ) ; lastLeftDown = false ; }
161+ releaseAllButtons ( ) ;
151162
152163 if ( actionMasked != MotionEventActions . HoverExit )
153164 return true ;
154165 }
166+ else if ( actionMasked == MotionEventActions . HoverEnter )
167+ {
168+ // Reset stale button/touch state across sleep / focus-regain cycles. The
169+ // previous hover session may have ended without a clean Up if the OS
170+ // dropped the activity; without this reset the next first sample can
171+ // strand `lastLeftDown=true` (or `lastTouchActive=true`) and produce a
172+ // phantom hold from wherever the cursor last was.
173+ releaseAllButtons ( ) ;
174+ }
155175
156176 // Process all batched historical events for maximum accuracy.
157177 int historySize = e . HistorySize ;
@@ -163,6 +183,24 @@ public bool HandleMotionEvent(MotionEvent e)
163183 return true ;
164184 }
165185
186+ [ MethodImpl ( MethodImplOptions . AggressiveInlining ) ]
187+ private void releaseAllButtons ( )
188+ {
189+ if ( lastLeftDown )
190+ {
191+ PendingInputs . Enqueue ( new MouseButtonInput ( MouseButton . Left , false ) ) ;
192+ lastLeftDown = false ;
193+ }
194+
195+ if ( lastTouchActive )
196+ {
197+ PendingInputs . Enqueue ( new TouchInput ( new [ ] { new Touch ( TouchSource . Touch1 , lastTouchPosition ) } , false ) ) ;
198+ lastTouchActive = false ;
199+ }
200+ }
201+
202+ private Vector2 lastTouchPosition ;
203+
166204 [ MethodImpl ( MethodImplOptions . AggressiveInlining ) ]
167205 private void handlePointer ( MotionEvent e , int historyIndex , MotionEventActions actionMasked )
168206 {
@@ -173,6 +211,19 @@ private void handlePointer(MotionEvent e, int historyIndex, MotionEventActions a
173211 float rawY = historyIndex < 0 ? e . GetY ( pointer_index ) : e . GetHistoricalY ( pointer_index , historyIndex ) ;
174212 float pressure = historyIndex < 0 ? e . GetPressure ( pointer_index ) : e . GetHistoricalPressure ( pointer_index , historyIndex ) ;
175213
214+ // Drop (0, 0, 0) garbage samples. The Samsung digitizer occasionally emits a
215+ // single (rawX=0, rawY=0, pressure=0) sample when the pen wakes up after sleep,
216+ // when the activity regains focus, or as the very first HoverEnter sample
217+ // before the real coordinate is latched. Mapping that sample produces a snap
218+ // to the top-left of the screen — the long-standing "S Pen stuck top-left"
219+ // bug. A real pen sample would always have *some* coordinate (the pen is
220+ // physically *somewhere* on the digitizer to have triggered an event), so a
221+ // strict triple-zero match is a safe filter that doesn't drop legitimate
222+ // edge-of-digitizer samples (which would have pressure > 0 on contact, or
223+ // non-zero hover Y/X off the screen origin).
224+ if ( rawX == 0f && rawY == 0f && pressure == 0f )
225+ return ;
226+
176227 // Auto-expand tablet size if the digitizer reports coordinates beyond current bounds.
177228 // Compares against cached field values to avoid the bindable read + property access on
178229 // every historical sample (which can fire 5-20× per MotionEvent on busy stylus drags).
@@ -214,7 +265,18 @@ private void handlePointer(MotionEvent e, int historyIndex, MotionEventActions a
214265 mappedY = rawY ;
215266 }
216267
217- PendingInputs . Enqueue ( new MousePositionAbsoluteInput { Position = new Vector2 ( mappedX , mappedY ) } ) ;
268+ var mappedPos = new Vector2 ( mappedX , mappedY ) ;
269+
270+ // Belt-and-braces: drop pathologically out-of-bounds mapped samples. A
271+ // half-initialised digitizer or a device-specific firmware glitch can emit
272+ // raw coordinates a few orders of magnitude beyond the actual screen — those
273+ // map to coordinates several screens away and visibly fling the cursor.
274+ // The ±2x output-area window is generous enough to keep legitimate
275+ // off-area samples (hover near the screen edge, area-rotation overshoot)
276+ // while rejecting the obvious garbage.
277+ if ( mappedX < outLeft - 2f * outWidth || mappedX > outLeft + 3f * outWidth
278+ || mappedY < outTop - 2f * outHeight || mappedY > outTop + 3f * outHeight )
279+ return ;
218280
219281 // Button state: pressure-based click (primary) with action overrides.
220282 // Uses the cached threshold field rather than `PressureThreshold.Value` to skip the
@@ -230,10 +292,52 @@ private void handlePointer(MotionEvent e, int historyIndex, MotionEventActions a
230292 else if ( actionMasked == MotionEventActions . Up || actionMasked == MotionEventActions . ButtonRelease || actionMasked == MotionEventActions . Cancel ) isLeftDown = false ;
231293 else if ( actionMasked == MotionEventActions . Move && ( buttonState & MotionEventButtonState . Primary ) != 0 ) isLeftDown = true ;
232294
233- if ( isLeftDown != lastLeftDown )
295+ if ( TreatAsTouch )
234296 {
235- PendingInputs . Enqueue ( new MouseButtonInput ( MouseButton . Left , isLeftDown ) ) ;
236- lastLeftDown = isLeftDown ;
297+ // Route as a Touch1 event so the gameplay paths that only fire on real
298+ // touch input (osu! relax/touch-device mod, mania touch columns, mobile
299+ // tap suppression toggles, etc.) treat the S Pen as a finger.
300+ //
301+ // Two queue items per state change:
302+ // - Position update (always, so hover-only motion still moves the touch
303+ // point — needed for slider drawing in the editor and for the
304+ // OsuTouchInputMapper to track the active touch).
305+ // - Activate/deactivate when contact state changes.
306+ //
307+ // The companion mouse-pipeline state is force-released so a runtime toggle
308+ // of the setting doesn't strand a phantom MouseButton.Left=true.
309+ if ( lastLeftDown )
310+ {
311+ PendingInputs . Enqueue ( new MouseButtonInput ( MouseButton . Left , false ) ) ;
312+ lastLeftDown = false ;
313+ }
314+
315+ lastTouchPosition = mappedPos ;
316+
317+ // Position update (always emitted while the touch is active or starting).
318+ if ( isLeftDown || lastTouchActive )
319+ PendingInputs . Enqueue ( new TouchInput ( new [ ] { new Touch ( TouchSource . Touch1 , mappedPos ) } , isLeftDown ) ) ;
320+
321+ if ( isLeftDown != lastTouchActive )
322+ lastTouchActive = isLeftDown ;
323+ }
324+ else
325+ {
326+ // Mouse-pipeline path. Position is published as MousePositionAbsoluteInput
327+ // so the desktop-style cursor tracks the pen tip even when not in contact.
328+ PendingInputs . Enqueue ( new MousePositionAbsoluteInput { Position = mappedPos } ) ;
329+
330+ if ( lastTouchActive )
331+ {
332+ PendingInputs . Enqueue ( new TouchInput ( new [ ] { new Touch ( TouchSource . Touch1 , lastTouchPosition ) } , false ) ) ;
333+ lastTouchActive = false ;
334+ }
335+
336+ if ( isLeftDown != lastLeftDown )
337+ {
338+ PendingInputs . Enqueue ( new MouseButtonInput ( MouseButton . Left , isLeftDown ) ) ;
339+ lastLeftDown = isLeftDown ;
340+ }
237341 }
238342
239343 // S Pen side button and eraser tip are intentionally NOT mapped to right/middle
0 commit comments