From e1d233d026ba06d6ecde90624c792abcc2efea97 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:07:54 +0200 Subject: [PATCH] fix(android): rebind secondary displays after hotplug --- .../java/org/love2d/android/GameActivity.java | 68 ++++++++++++++++++- tests/engine/android_host_extension_test.lua | 10 +++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 7a30850c..cec24cde 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -366,6 +366,7 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + unregisterSecondaryDisplayListener(); onHostDestroy(); super.onDestroy(); } @@ -376,6 +377,7 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + unregisterSecondaryDisplayListener(); teardownSecondaryDisplay(); onHostPause(); super.onPause(); @@ -385,6 +387,7 @@ public class GameActivity extends SDLActivity { public void onResume() { super.onResume(); onHostResume(); + if (secondaryEnabled) registerSecondaryDisplayListener(); setupSecondaryDisplay(); } @@ -1405,6 +1408,7 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private SecondaryDisplayMonitor secondaryDisplayMonitor; private static final int MAX_SECONDARY_TOUCHES = 32; private static final java.util.ArrayDeque secondaryTouches = new java.util.ArrayDeque<>(); @@ -1416,11 +1420,44 @@ public class GameActivity extends SDLActivity { if (self == null) return; self.runOnUiThread(new Runnable() { @Override public void run() { - if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay(); + if (on) { + self.registerSecondaryDisplayListener(); + setupSecondaryDisplay(); + } else { + self.unregisterSecondaryDisplayListener(); + teardownSecondaryDisplay(); + } } }); } + private void registerSecondaryDisplayListener() { + if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return; + SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this); + if (monitor.register()) secondaryDisplayMonitor = monitor; + } + + private void unregisterSecondaryDisplayListener() { + SecondaryDisplayMonitor monitor = secondaryDisplayMonitor; + secondaryDisplayMonitor = null; + if (monitor != null) monitor.unregister(); + } + + private static void refreshSecondaryDisplay() { + GameActivity self = (GameActivity) mSingleton; + if (self == null || !secondaryEnabled) return; + SecondaryPresentation current = secondaryPresentation; + Display display = current == null ? null : current.getDisplay(); + SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor; + if (current == null) { + setupSecondaryDisplay(); + } else if (display == null || monitor == null + || !monitor.hasDisplay(display.getDisplayId())) { + teardownSecondaryDisplay(); + setupSecondaryDisplay(); + } + } + private static void setupSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; if (self == null || !secondaryEnabled || secondaryPresentation != null) return; @@ -1466,6 +1503,35 @@ public class GameActivity extends SDLActivity { } } + @android.annotation.TargetApi(17) + private static class SecondaryDisplayMonitor + implements android.hardware.display.DisplayManager.DisplayListener { + private final android.hardware.display.DisplayManager manager; + + SecondaryDisplayMonitor(GameActivity activity) { + manager = (android.hardware.display.DisplayManager) + activity.getSystemService(Context.DISPLAY_SERVICE); + } + + boolean register() { + if (manager == null) return false; + manager.registerDisplayListener(this, new Handler(Looper.getMainLooper())); + return true; + } + + void unregister() { + manager.unregisterDisplayListener(this); + } + + boolean hasDisplay(int displayId) { + return manager.getDisplay(displayId) != null; + } + + @Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); } + @Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); } + @Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); } + } + @Keep public static boolean hasSecondaryDisplay() { return secondaryPresentation != null; diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 13d25a5e..aabef3f7 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"), check(position("onHostDestroy();") < position("super.onDestroy();"), "destroy hook runs before SDL destruction") +check(source:find("DisplayManager.DisplayListener", 1, true) + and source:find("registerDisplayListener", 1, true) + and source:find("unregisterDisplayListener", 1, true), + "secondary displays are monitored while the activity is active") +check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") < + position("setupSecondaryDisplay();"), + "secondary display monitoring starts before initial discovery") +check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true), + "a disconnected active display is rebound without replacing a live one") + check(not source:lower():find("openxr", 1, true), "generic Android activity must not require OpenXR") check(not source:find("QuestActivity", 1, true) and