Skip to content

Commit d6d9b0d

Browse files
authored
Merge pull request #264 from winnerspiros/copilot/fix-logging-issues-and-prioritize-fork
Android: fix S Pen/mouse/keyboard input, hardware Back, import errors, sync upstream, pin Oboe
2 parents ac54d75 + 3fd2ad6 commit d6d9b0d

18 files changed

Lines changed: 294 additions & 91 deletions

README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,14 @@ Settings → Graphics → Renderer now exposes the full set of fork-added option
179179

180180
---
181181

182-
### 🛡️ Stability improvements
182+
### 🛡️ Robustness improvements
183183

184-
This fork includes several hardening fixes on top of upstream:
184+
A few hardening fixes on top of upstream that are not directly performance-related but keep the app behaving correctly across edge cases:
185185

186-
- **Multi-threaded execution lock-in (v145+)** — the framework's `ExecutionMode = SingleThread` is force-set to `MultiThreaded` on every startup and the threading-mode toggle is removed from Settings → Graphics → Renderer. SingleThread on Android collapsed the SDL/Vulkan thread onto the same thread that delivers the `SurfaceHolder.Callback`, so `VeldridDevice`'s 5-second `SurfaceHandle` poll deadlocked and Vulkan device creation crashed on a null function pointer (`SDLThread` `SI_TKILL` ~5 s into launch). The same risk applies to iOS Metal drawable attach; on desktop SingleThread is strictly slower with no UX benefit, so the lock-in is unconditional across all platforms.
187-
- **Sentry-safe init** — the app gracefully handles a missing/placeholder Sentry DSN instead of failing on startup
188-
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them
189-
- **JNI surface safety** — proper lifecycle management with atomic swaps and timeouts to prevent race conditions between Android surface creation and destruction
190-
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming so release builds behave the same as debug
191-
- **Architecture-correct native libraries (v144+)**`osu.Android.props` strips desktop runtime `.so` files (`runtimes/{linux,osx,ios,maccatalyst,win,…}-*/native/`) from the Android publish set and only marks Android-RID assets as `AssetType=native`, so the proper Android arm64 BASS libraries from `ppy.osu.Framework.Android`'s AAR (`jni/arm64-v8a/`) always win over the desktop `.so` files transitively pulled in by `ppy.osu.Framework.NativeLibs`. The release workflow scans every shipped `libbass*.so` for `GLIBC_*` versioned symbols (only present in glibc-linked Linux ELFs) and fails the build if any are found, so an architecture mismatch can never reach a release.
192-
- **IPC / WebSocket hardening (v144+)** — desktop external-integrations server (env-var gated, `localhost`-only) tightened on top of upstream: `WebSocketChannel` now uses a strict `UTF8Encoding(throwOnInvalidBytes: true)` decoder so malformed payloads are rejected with `InvalidPayloadData` instead of being silently replaced with `U+FFFD`, and the message-size guard accepts payloads of exactly `max_message_size` bytes (was off-by-one); `WebSocketServer.Dispose()` now cancels the request loop and waits briefly for it to exit before tearing down the cancellation/reset-event handles to avoid an `ObjectDisposedException` race on shutdown; `OsuWebSocketProvider.Dispose()` swaps the server reference under a local, properly disposes the bounded `CancellationTokenSource` via `using`, and always disposes the `WebSocketServer` in a `finally` so listener handles can't leak across screen transitions.
186+
- **Multi-threaded execution lock-in (v145+)** — the framework's `ExecutionMode = SingleThread` is force-set to `MultiThreaded` on every startup and the threading-mode toggle is removed from Settings → Graphics → Renderer. SingleThread is strictly slower than MultiThreaded with no UX benefit, so the lock-in is unconditional across all platforms.
187+
- **Sentry-safe init** — the app gracefully handles a missing/placeholder Sentry DSN.
188+
- **Architecture-correct native libraries (v144+)**`osu.Android.props` strips desktop runtime `.so` files (`runtimes/{linux,osx,ios,maccatalyst,win,…}-*/native/`) from the Android publish set and only marks Android-RID assets as `AssetType=native`, so the proper Android arm64 BASS libraries from `ppy.osu.Framework.Android`'s AAR (`jni/arm64-v8a/`) always win over the desktop `.so` files transitively pulled in by `ppy.osu.Framework.NativeLibs`. The release workflow scans every shipped `libbass*.so` for `GLIBC_*` versioned symbols (only present in glibc-linked Linux ELFs) and fails the build if any are found.
189+
- **IPC / WebSocket polish (v144+)** — desktop external-integrations server (env-var gated, `localhost`-only) tightened on top of upstream: `WebSocketChannel` now uses a strict `UTF8Encoding(throwOnInvalidBytes: true)` decoder so malformed payloads are rejected with `InvalidPayloadData` instead of being silently replaced with `U+FFFD`, and the message-size guard accepts payloads of exactly `max_message_size` bytes (was off-by-one); `WebSocketServer.Dispose()` cancels the request loop and waits briefly for it to exit before tearing down the cancellation/reset-event handles; `OsuWebSocketProvider.Dispose()` swaps the server reference under a local, properly disposes the bounded `CancellationTokenSource` via `using`, and always disposes the `WebSocketServer` in a `finally` so listener handles can't leak across screen transitions.
193190
- **Ranked-play song-preview playback (v144+)** — restored the `Enabled`/`CardHovered``PreviewTrack.Start()/Stop()` wiring on `RankedPlayCard.SongPreviewContainer` that was lost in upstream's "playback rewrite" merge. The bind now happens in the `LoadComponentAsync` continuation (so previews never race the track's async load), with both bindables driving a single `updatePlaybackState()` callback.
194191

195192
---
@@ -251,11 +248,11 @@ The [winnerspiros/osu-framework](https://github.com/winnerspiros/osu-framework)
251248
**Platform targeting:**
252249
- Full `osu.Framework.Android` / `osu.Framework.iOS` implementations.
253250
- Android minimum bumped to **API 33** (matches app manifest), target API 36.
254-
- Android release config: profiled AOT (`AndroidEnableProfiledAot`), partial trimming, `AndroidStripILAfterAOT=false` (avoids `plt_entry` crashes), no LLVM (incompatible with profiled AOT).
251+
- Android release config: profiled AOT (`AndroidEnableProfiledAot`), partial trimming, `AndroidStripILAfterAOT=false`, no LLVM (incompatible with profiled AOT).
255252
- iOS: `SupportedOSPlatformVersion` 13.4, trim-analysis warnings suppressed with `[DynamicallyAccessedMembers]` and `[UnconditionalSuppressMessage]`.
256253

257-
**Stability fixes consumed by the Android build:**
258-
- Null `ANativeWindow` guard in `VkSurfaceUtil` prevents SIGSEGV at `pc=0x0` from invalid Vulkan function pointers.
254+
**Android-specific framework polish:**
255+
- Null `ANativeWindow` guard in `VkSurfaceUtil`.
259256
- `VeldridDevice` polls `SurfaceHandle` for up to 5 s when the Android surface is not yet ready.
260257
- `DrawThread.OnInitialize()` wraps the initial `BeginFrame` in try-catch for graceful handling before surface readiness.
261258
- NRE fix in `GraphicsPipeline.cs` (null-conditional `ResourceLayouts?.Length`).

osu.Android/AndroidImportTask.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
22
// See the LICENCE file in the repository root for full licence text.
33

4+
using System;
45
using System.IO;
56
using System.Threading.Tasks;
67
using Android.Content;
7-
using Android.Net;
88
using Android.Provider;
9+
using osu.Framework.Logging;
910
using osu.Game.Database;
11+
using Uri = Android.Net.Uri;
1012

1113
namespace osu.Android
1214
{
@@ -25,7 +27,24 @@ private AndroidImportTask(Stream stream, string filename, ContentResolver conten
2527

2628
public override void DeleteFile()
2729
{
28-
contentResolver.Delete(uri, null, null);
30+
try
31+
{
32+
contentResolver.Delete(uri, null, null);
33+
}
34+
catch (Java.Lang.SecurityException e)
35+
{
36+
// Some third-party file managers (notably MIUI's `com.android.fileexplorer`)
37+
// share content URIs without granting the receiving app write permission, so
38+
// the post-import source-file delete throws SecurityException. The import
39+
// itself has already succeeded by this point; failing to delete the original
40+
// is a best-effort cleanup, so swallow the exception and log at info level
41+
// instead of letting it surface as a user-facing red error notification.
42+
Logger.Log($"Skipped deleting imported source file (provider denied write access): {e.Message}", LoggingTarget.Database);
43+
}
44+
catch (Exception e)
45+
{
46+
Logger.Log($"Failed to delete imported source file: {e.Message}", LoggingTarget.Database);
47+
}
2948
}
3049

3150
public static async Task<AndroidImportTask?> Create(ContentResolver contentResolver, Uri uri)

osu.Android/Input/AndroidKeyboardHandler.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ public AndroidKeyboardHandler()
6060
Enabled.Value = true;
6161
}
6262

63-
public override bool Initialize(GameHost host) => true;
63+
public override bool Initialize(GameHost host)
64+
{
65+
if (!base.Initialize(host))
66+
return false;
67+
68+
return true;
69+
}
6470

6571
[MethodImpl(MethodImplOptions.AggressiveInlining)]
6672
public bool HandleKeyEvent(KeyEvent e)

osu.Android/Input/AndroidMouseHandler.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ public AndroidMouseHandler()
2828
Enabled.Value = true;
2929
}
3030

31-
public override bool Initialize(GameHost host) => true;
31+
public override bool Initialize(GameHost host)
32+
{
33+
if (!base.Initialize(host))
34+
return false;
35+
36+
return true;
37+
}
3238

3339
[MethodImpl(MethodImplOptions.AggressiveInlining)]
3440
public bool HandleMotionEvent(MotionEvent e)
@@ -64,15 +70,28 @@ private void handlePointer(MotionEvent e, int historyIndex)
6470

6571
PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(x, y) });
6672

73+
// Drive button state purely from MotionEvent.ButtonState — the bitmask is
74+
// already authoritative for which physical buttons are currently held, across
75+
// every action type (Move, ButtonPress, ButtonRelease, Down, Up). The previous
76+
// implementation also force-set `left = true` on any `Down`/`ButtonPress` and
77+
// `false` on any `Up`/`ButtonRelease`, which collapsed every right-click /
78+
// middle-click / forward-click into a spurious left-click (an osu! hit) — the
79+
// exact opposite of the README's "all 5 buttons" promise. ButtonState alone
80+
// already covers the touchscreen-Down case for trackpads (Primary bit set on
81+
// tap) so no override is needed.
82+
//
83+
// Mouse back/forward are exposed as MouseButton.Button1/Button2 (the README's
84+
// "all 5 buttons"). On devices that *additionally* synthesise a Keycode.Back
85+
// for the back button, OsuGameActivity.DispatchKeyEvent also translates that
86+
// into Escape for menu navigation (README: "Mouse back button = Escape"); the
87+
// small overlap on those devices is harmless because Button1 has no default
88+
// binding in osu! and so cannot trigger an unintended gameplay hit.
6789
bool left = (e.ButtonState & MotionEventButtonState.Primary) != 0;
6890
bool right = (e.ButtonState & MotionEventButtonState.Secondary) != 0;
6991
bool middle = (e.ButtonState & MotionEventButtonState.Tertiary) != 0;
7092
bool back = (e.ButtonState & MotionEventButtonState.Back) != 0;
7193
bool forward = (e.ButtonState & MotionEventButtonState.Forward) != 0;
7294

73-
if (e.ActionMasked == MotionEventActions.Down || e.ActionMasked == MotionEventActions.ButtonPress) left = true;
74-
if (e.ActionMasked == MotionEventActions.Up || e.ActionMasked == MotionEventActions.ButtonRelease) left = false;
75-
7695
if (left != lastLeft) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Left, left)); lastLeft = left; }
7796
if (right != lastRight) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Right, right)); lastRight = right; }
7897
if (middle != lastMiddle) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Middle, middle)); lastMiddle = middle; }

osu.Android/Input/AndroidStylusHandler.cs

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
4747
private float areaLeft, areaTop, areaWidth, areaHeight;
4848
private float outLeft, outTop, outWidth, outHeight;
4949
private float rotSin, rotCos;
50+
private bool useRotation;
51+
private float cachedPressureThreshold;
52+
53+
// Cached tablet bounds — updated whenever `tablet.Value` is reassigned. Avoids
54+
// three bindable reads + property accesses per historical pointer sample in the
55+
// hot path. A local-field comparison is a single un-locked memory read.
56+
private float cachedTabletSizeX = 1920;
57+
private float cachedTabletSizeY = 1080;
5058

5159
private const float deg_to_rad = MathF.PI / 180f;
5260

@@ -66,6 +74,7 @@ public override bool Initialize(GameHost host)
6674
OutputAreaSize.BindValueChanged(_ => updateCachedTransform());
6775
OutputAreaOffset.BindValueChanged(_ => updateCachedTransform());
6876
Rotation.BindValueChanged(_ => updateCachedTransform());
77+
PressureThreshold.BindValueChanged(v => cachedPressureThreshold = v.NewValue, true);
6978

7079
return base.Initialize(host);
7180
}
@@ -78,6 +87,8 @@ public void SetDisplaySize(int width, int height)
7887
{
7988
var size = new Vector2(width, height);
8089
tablet.Value = new TabletInfo("S Pen", size);
90+
cachedTabletSizeX = width;
91+
cachedTabletSizeY = height;
8192

8293
// Default: full digitizer area mapped to full screen (1:1 passthrough).
8394
AreaSize.Default = size;
@@ -117,7 +128,9 @@ private void updateCachedTransform()
117128
outWidth = oSize.X;
118129
outHeight = oSize.Y;
119130

120-
float radians = deg_to_rad * Rotation.Value;
131+
float rotation = Rotation.Value;
132+
useRotation = rotation != 0;
133+
float radians = deg_to_rad * rotation;
121134
rotSin = MathF.Sin(radians);
122135
rotCos = MathF.Cos(radians);
123136
}
@@ -156,11 +169,15 @@ private void handlePointer(MotionEvent e, int historyIndex)
156169
float pressure = historyIndex < 0 ? e.GetPressure(pointer_index) : e.GetHistoricalPressure(pointer_index, historyIndex);
157170

158171
// Auto-expand tablet size if the digitizer reports coordinates beyond current bounds.
159-
if (tablet.Value == null || rawX > tablet.Value.Size.X || rawY > tablet.Value.Size.Y)
172+
// Compares against cached field values to avoid the bindable read + property access on
173+
// every historical sample (which can fire 5-20× per MotionEvent on busy stylus drags).
174+
if (rawX > cachedTabletSizeX || rawY > cachedTabletSizeY)
160175
{
161-
var currentSize = tablet.Value?.Size ?? Vector2.Zero;
162-
var newSize = new Vector2(Math.Max(rawX + 1, currentSize.X), Math.Max(rawY + 1, currentSize.Y));
163-
tablet.Value = new TabletInfo("S Pen", newSize);
176+
float newW = MathF.Max(rawX + 1, cachedTabletSizeX);
177+
float newH = MathF.Max(rawY + 1, cachedTabletSizeY);
178+
cachedTabletSizeX = newW;
179+
cachedTabletSizeY = newH;
180+
tablet.Value = new TabletInfo("S Pen", new Vector2(newW, newH));
164181
}
165182

166183
// Apply tablet area → output area coordinate mapping.
@@ -173,7 +190,7 @@ private void handlePointer(MotionEvent e, int historyIndex)
173190
float normY = (rawY - areaTop) / areaHeight;
174191

175192
// Apply rotation around center of normalized space.
176-
if (Rotation.Value != 0)
193+
if (useRotation)
177194
{
178195
float cx = normX - 0.5f;
179196
float cy = normY - 0.5f;
@@ -195,10 +212,14 @@ private void handlePointer(MotionEvent e, int historyIndex)
195212
PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(mappedX, mappedY) });
196213

197214
// Button state: pressure-based click (primary) with action overrides.
198-
bool isLeftDown = pressure >= PressureThreshold.Value;
199-
if (e.ActionMasked == MotionEventActions.Down || e.ActionMasked == MotionEventActions.ButtonPress) isLeftDown = true;
200-
else if (e.ActionMasked == MotionEventActions.Up || e.ActionMasked == MotionEventActions.ButtonRelease || e.ActionMasked == MotionEventActions.Cancel) isLeftDown = false;
201-
else if (e.ActionMasked == MotionEventActions.Move && (e.ButtonState & MotionEventButtonState.Primary) != 0) isLeftDown = true;
215+
// Uses the cached threshold field rather than `PressureThreshold.Value` to skip the
216+
// per-event bindable read.
217+
var actionMasked = e.ActionMasked;
218+
var buttonState = e.ButtonState;
219+
bool isLeftDown = pressure >= cachedPressureThreshold;
220+
if (actionMasked == MotionEventActions.Down || actionMasked == MotionEventActions.ButtonPress) isLeftDown = true;
221+
else if (actionMasked == MotionEventActions.Up || actionMasked == MotionEventActions.ButtonRelease || actionMasked == MotionEventActions.Cancel) isLeftDown = false;
222+
else if (actionMasked == MotionEventActions.Move && (buttonState & MotionEventButtonState.Primary) != 0) isLeftDown = true;
202223

203224
if (isLeftDown != lastLeftDown)
204225
{
@@ -207,15 +228,15 @@ private void handlePointer(MotionEvent e, int historyIndex)
207228
}
208229

209230
// S Pen button → right click.
210-
bool isRightDown = (e.ButtonState & MotionEventButtonState.StylusPrimary) != 0;
231+
bool isRightDown = (buttonState & MotionEventButtonState.StylusPrimary) != 0;
211232
if (isRightDown != lastRightDown)
212233
{
213234
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Right, isRightDown));
214235
lastRightDown = isRightDown;
215236
}
216237

217238
// Eraser → middle click.
218-
bool isEraserDown = (e.ButtonState & MotionEventButtonState.StylusSecondary) != 0 || e.GetToolType(pointer_index) == MotionEventToolType.Eraser;
239+
bool isEraserDown = (buttonState & MotionEventButtonState.StylusSecondary) != 0 || e.GetToolType(pointer_index) == MotionEventToolType.Eraser;
219240
if (isEraserDown != lastEraserDown)
220241
{
221242
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Middle, isEraserDown));

0 commit comments

Comments
 (0)