Skip to content

Commit f9787ec

Browse files
authored
Merge pull request #228 from winnerspiros/copilot/fix-apk-crash-on-start-again
Fix Android startup crash: lock activity to SensorLandscape in manifest
2 parents 3f40d8f + 2e2a3f1 commit f9787ec

3 files changed

Lines changed: 87 additions & 53 deletions

File tree

osu.Android.props

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,23 @@
2424
<NoWarn>$(NoWarn);NU1608;XA4301</NoWarn>
2525
</PropertyGroup>
2626

27-
<!-- Release-only optimisations: AOT for low-latency gameplay, trimming for smaller APK.
28-
Suppress trim analysis warnings because the project uses reflection extensively
29-
(Newtonsoft.Json, Realm, AutoMapper, RuntimeBinder). -->
27+
<!-- Release-only optimisations.
28+
Trimming and AOT were previously enabled here for size/startup, but caused a
29+
reproducible "splash → black screen → crash, no logs" failure on real devices.
30+
Root cause: PublishTrimmed + TrimMode=partial + AndroidEnableProfiledAot, with
31+
SuppressTrimAnalysisWarnings=true masking the build-time signal. Only Microsoft.CSharp
32+
and ppy.Veldrid.SPIRV were rooted — osu.Game, the rulesets, Realm, Newtonsoft.Json,
33+
AutoMapper, Sentry, OsuTK and other reflection-heavy assemblies were not. A trimmed
34+
method/type or an un-AOT'd profiled-AOT call site throws TypeLoadException /
35+
MissingMethodException early in OsuGame construction, before the file logger is open;
36+
the process dies on the SDLThread / .NET ThreadPool thread with only a tombstone, so
37+
the user gets no osu.log and Logcat shows a bare native crash.
38+
Upstream ppy/osu's osu.Android.props enables NEITHER trimming NOR AOT — we now match
39+
that baseline. The remaining release-only knobs (assembly compression, no PDBs) are
40+
safe and unrelated to the startup crash. To re-enable trimming/AOT in the future,
41+
reintroduce them one at a time, root every reflection-using assembly explicitly, and
42+
run with SuppressTrimAnalysisWarnings=false to surface the gaps before shipping. -->
3043
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
31-
<RunAOTCompilation>true</RunAOTCompilation>
32-
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
33-
<!-- Do NOT use EnableLLVM with profiled AOT on .NET 10.
34-
The LLVM backend generates internally-inconsistent PLT (Procedure Linkage Table)
35-
entries when only a subset of methods is AOT-compiled (profiled AOT). This causes
36-
'plt_entry not met' assertions in aot-runtime.c at startup (SIGABRT on SDLThread).
37-
The default Mono AOT compiler handles partial-image PLT generation correctly. -->
38-
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
39-
<PublishTrimmed>true</PublishTrimmed>
40-
<TrimMode>partial</TrimMode>
41-
<!-- Keep IL bodies as a fallback for methods not covered by profiled AOT.
42-
AndroidEnableProfiledAot only AOT-compiles methods in the startup profile; the remaining
43-
methods require IL for JIT/interpretation. Stripping IL (AndroidStripILAfterAOT=true)
44-
removes that fallback, causing 'plt_entry not met' assertions in aot-runtime.c when
45-
the runtime encounters an un-AOT'd method (observed on .NET Timer thread at startup).
46-
The ~20-30 MB size saving is not worth the crash risk. -->
47-
<AndroidStripILAfterAOT>false</AndroidStripILAfterAOT>
4844
<!-- Compress managed assemblies inside the APK (LZ4). Android extracts them on first run
4945
but the download/APK size is significantly smaller. -->
5046
<AndroidEnableAssemblyCompression>true</AndroidEnableAssemblyCompression>
@@ -54,20 +50,6 @@
5450
<DebugSymbols>false</DebugSymbols>
5551
</PropertyGroup>
5652

57-
<ItemGroup Condition="'$(Configuration)' == 'Release'">
58-
<!-- Force Microsoft.CSharp into the trimmer input graph (.NET 10+). -->
59-
<!-- A bare TrimmerRootAssembly is ignored if the assembly is not in the -->
60-
<!-- linker's input set; adding this reference ensures it is included. -->
61-
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" NoWarn="NU1510" />
62-
<TrimmerRootAssembly Include="Microsoft.CSharp" RootMode="all" />
63-
<!-- Preserve ppy.Veldrid.SPIRV from trimming. The trimmer does not trace the
64-
CrossCompileTarget enum usage in osu.Framework's VeldridShader/GLShader correctly
65-
(the reference crosses assembly boundaries via a ProjectReference chain), causing
66-
TypeLoadException: 'Could not resolve type Veldrid.SPIRV.CrossCompileTarget' at
67-
startup when the renderer tries to compile shaders. -->
68-
<TrimmerRootAssembly Include="ppy.Veldrid.SPIRV" RootMode="all" />
69-
</ItemGroup>
70-
7153
<ItemGroup>
7254
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.420.2" />
7355
<!-- `ppy.osu.Framework.NativeLibs` is a transitive dependency of `ppy.osu.Framework`

osu.Android/OsuGameActivity.cs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323

2424
namespace osu.Android
2525
{
26-
[Activity(ResizeableActivity = true, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode | ConfigChanges.SmallestScreenSize | ConfigChanges.ScreenLayout | ConfigChanges.ColorMode | ConfigChanges.Density | ConfigChanges.Touchscreen | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden | ConfigChanges.Navigation, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)]
26+
// Declare ScreenOrientation in the manifest (rather than only assigning RequestedOrientation
27+
// at runtime in OnCreate) so Android creates the activity in landscape from the very first
28+
// frame — the SurfaceView is sized correctly on creation and there is no orientation-change
29+
// event during startup. This is defensive hardening alongside the main fix in osu.Android.props
30+
// (disabling trimming + profiled AOT, which was the actual cause of the startup crash).
31+
[Activity(ResizeableActivity = true, ScreenOrientation = ScreenOrientation.SensorLandscape, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode | ConfigChanges.SmallestScreenSize | ConfigChanges.ScreenLayout | ConfigChanges.ColorMode | ConfigChanges.Density | ConfigChanges.Touchscreen | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden | ConfigChanges.Navigation, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)]
2732
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osz", DataHost = "*", DataMimeType = "*/*")]
2833
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osk", DataHost = "*", DataMimeType = "*/*")]
2934
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osr", DataHost = "*", DataMimeType = "*/*")]
@@ -81,9 +86,35 @@ protected override void OnCreate(Bundle? savedInstanceState)
8186
{
8287
base.OnCreate(savedInstanceState);
8388

84-
Microsoft.Maui.ApplicationModel.Platform.Init(this, savedInstanceState);
89+
// Wrap Platform.Init defensively: MAUI Essentials pulls in workload-version-sensitive
90+
// initialisation code, and a mismatch between the build-time workload and the device's
91+
// runtime can throw TypeLoadException/MissingMethodException on the UI thread before
92+
// the managed logger is up — users would see only a native tombstone with no osu.log.
93+
try
94+
{
95+
Microsoft.Maui.ApplicationModel.Platform.Init(this, savedInstanceState);
96+
}
97+
catch (Exception e)
98+
{
99+
Debug.WriteLine($"[osu!] MAUI Platform.Init failed (non-fatal): {e.Message}");
100+
}
101+
85102
updateDeXStatus(null);
86-
Window?.DecorView.Post(() => GetSurface()?.Holder?.AddCallback(this));
103+
104+
// Posting the surface-callback registration onto the UI thread loop is intentional
105+
// (the SurfaceView may not be attached yet at OnCreate time). Guard the body of the
106+
// lambda — a later race with activity teardown can make AddCallback throw.
107+
Window?.DecorView.Post(() =>
108+
{
109+
try
110+
{
111+
GetSurface()?.Holder?.AddCallback(this);
112+
}
113+
catch (Exception e)
114+
{
115+
Debug.WriteLine($"[osu!] Failed to register SurfaceHolder callback: {e.Message}");
116+
}
117+
});
87118

88119
handleIntent(Intent);
89120

@@ -122,7 +153,16 @@ protected override void OnCreate(Bundle? savedInstanceState)
122153
if (Resources?.Configuration != null)
123154
IsTablet = Resources.Configuration.SmallestScreenWidthDp >= 600;
124155

125-
RequestedOrientation = DefaultOrientation = IsTablet ? ScreenOrientation.FullUser : ScreenOrientation.SensorLandscape;
156+
// Phones: manifest already requests SensorLandscape; do not re-assign at runtime —
157+
// a no-op assignment is harmless on most devices but a redundant RequestedOrientation
158+
// write can still nudge the SurfaceView into a recreate cycle on some OEMs while the
159+
// SDL draw thread is mid-Vulkan-init. Tablets get a more permissive policy applied
160+
// here; the SurfaceView is already up by this point and the framework handles
161+
// post-init surface resize cleanly.
162+
if (IsTablet)
163+
RequestedOrientation = DefaultOrientation = ScreenOrientation.FullUser;
164+
else
165+
DefaultOrientation = ScreenOrientation.SensorLandscape;
126166

127167
foreach (string asm in new[] { "osu.Game.Rulesets.Osu", "osu.Game.Rulesets.Taiko", "osu.Game.Rulesets.Catch", "osu.Game.Rulesets.Mania" })
128168
{

osu.Android/OsuGameAndroid.cs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -227,25 +227,37 @@ protected override void LoadComplete()
227227

228228
Scheduler.Add(() =>
229229
{
230-
Host.DrawThread.Scheduler.Add(() =>
230+
try
231231
{
232-
try
232+
Host?.DrawThread?.Scheduler.Add(() =>
233233
{
234-
if (OboeAudioBridge.nSetThreadAffinity(mask) != 0) Logger.Log("[osu!] Render thread pinned to big cores", LoggingTarget.Performance);
235-
global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay);
236-
}
237-
catch { }
238-
});
234+
try
235+
{
236+
if (OboeAudioBridge.nSetThreadAffinity(mask) != 0) Logger.Log("[osu!] Render thread pinned to big cores", LoggingTarget.Performance);
237+
global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay);
238+
}
239+
catch { }
240+
});
239241

240-
Host.InputThread.Scheduler.Add(() =>
241-
{
242-
try
242+
Host?.InputThread?.Scheduler.Add(() =>
243243
{
244-
if (OboeAudioBridge.nSetThreadAffinity(mask) != 0) Logger.Log("[osu!] Input thread pinned to big cores", LoggingTarget.Performance);
245-
global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay);
246-
}
247-
catch { }
248-
});
244+
try
245+
{
246+
if (OboeAudioBridge.nSetThreadAffinity(mask) != 0) Logger.Log("[osu!] Input thread pinned to big cores", LoggingTarget.Performance);
247+
global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay);
248+
}
249+
catch { }
250+
});
251+
}
252+
catch (Exception e)
253+
{
254+
// The enclosing try/catch only covers the Scheduler.Add call — not the
255+
// lambda body, which runs later on the update thread. Guard here so an
256+
// NRE from Host.DrawThread/Host.InputThread being null (or a Host
257+
// teardown race during startup) can't escape as an unhandled update-
258+
// thread exception and kill the framework.
259+
Debug.WriteLine($"[osu!] Failed to enqueue thread-affinity pinning for render/input threads: {e.Message}");
260+
}
249261
});
250262
}
251263
catch (Exception e)

0 commit comments

Comments
 (0)