From 360b69296332be144a5a4529c828570afadad1e6 Mon Sep 17 00:00:00 2001
From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com>
Date: Mon, 17 Aug 2026 03:00:05 +0200
Subject: [PATCH] android: route asymmetric companion displays
---
docs/modding.md | 3 +
.../android/app/src/main/AndroidManifest.xml | 13 +
.../love/src/jni/love/src/common/android.cpp | 14 +
.../java/org/love2d/android/GameActivity.java | 348 +++++++++++++++---
src/render/SecondScreen.lua | 15 +
tests/engine/android_asymmetric_display.lua | 35 ++
tests/engine/android_secondary_present.lua | 3 +-
tests/engine/second_screen_present.lua | 6 +
8 files changed, 384 insertions(+), 53 deletions(-)
create mode 100644 tests/engine/android_asymmetric_display.lua
diff --git a/docs/modding.md b/docs/modding.md
index 2786a563..e7c32c39 100644
--- a/docs/modding.md
+++ b/docs/modding.md
@@ -740,6 +740,9 @@ palette-correct blit of either canvas into an arbitrary screen rect, and the
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
arguments request an extended presentation; a preference ending in `:cover`
fills and crops the target, while other values preserve the whole frame.
+Android also accepts `handheld` or `secondary` (with an optional `:cover`
+suffix) as routing hints; unsupported or unavailable targets fall back to the
+other connected display.
`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 5cf7b38a..a2de3161 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -31,6 +31,9 @@
android:allowBackup="true"
android:icon="@drawable/love"
android:label="${NAME}" >
+
+
diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp
index 227e3aa3..3c327be4 100644
--- a/mobile/android/love/src/jni/love/src/common/android.cpp
+++ b/mobile/android/love/src/jni/love/src/common/android.cpp
@@ -1229,6 +1229,20 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity);
}
+extern "C" __attribute__((visibility("default")))
+void love_android_secondary_target(int target)
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID method = env->GetStaticMethodID(activity,
+ "setSecondaryDisplayTarget", "(I)V");
+ if (method)
+ env->CallStaticVoidMethod(activity, method, target);
+ else
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+}
+
extern "C" __attribute__((visibility("default")))
int love_android_secondary_detected()
{
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 31910959..aafd4af2 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
@@ -60,6 +60,7 @@ import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
+import android.provider.Settings;
import android.util.Log;
import android.util.DisplayMetrics;
import android.view.*;
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
public void onResume() {
super.onResume();
onHostResume();
+ refreshDualScreenDisplayMode();
if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay();
}
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // AYN's panel toggle emits virtual Right Shift, which SDL maps to a
+ // gameplay button. The setting is absent on other Android devices.
+ if (secondaryEnabled && dualScreenDisplayMode != -1
+ && event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
+ && event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
+ return true;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+
/**
* SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no
@@ -1476,8 +1490,21 @@ public class GameActivity extends SDLActivity {
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
+ private static final int SECONDARY_TARGET_AUTO = 0;
+ private static final int SECONDARY_TARGET_HANDHELD = 1;
+ private static final int SECONDARY_TARGET_EXTERNAL = 2;
+ // AYN keeps disabled panels registered as ON. This optional setting is the
+ // usable-state signal: 0 = both, 1 = main only, 2 = second only.
+ private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
+ private static final String AYN_SECOND_SCREEN = "Screen-2";
private static volatile SecondaryPresentation secondaryPresentation;
+ private static volatile SecondaryActivity secondaryActivity;
+ private static volatile boolean secondaryActivityPending;
+ private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
+ private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false;
+ private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
+ private static volatile int dualScreenDisplayMode = -1;
private static volatile byte[] secondaryFrame;
private static volatile int secondaryFrameWidth;
private static volatile int secondaryFrameHeight;
@@ -1487,6 +1514,14 @@ public class GameActivity extends SDLActivity {
private static volatile long secondaryDetectionAt;
private static volatile boolean secondaryDetected;
private SecondaryDisplayMonitor secondaryDisplayMonitor;
+ private boolean dualScreenModeObserverRegistered;
+ private final android.database.ContentObserver dualScreenModeObserver =
+ new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
+ @Override public void onChange(boolean selfChange, Uri uri) {
+ refreshDualScreenDisplayMode();
+ rebindSecondaryDisplay();
+ }
+ };
private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque secondaryTouches =
new java.util.ArrayDeque<>();
@@ -1499,56 +1534,125 @@ public class GameActivity extends SDLActivity {
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) {
+ self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener();
- setupSecondaryDisplay();
+ rebindSecondaryDisplay();
} else {
self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
+ secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) { secondaryFrame = null; }
}
}
});
}
+ @Keep
+ public static void setSecondaryDisplayTarget(int target) {
+ int normalized = target == SECONDARY_TARGET_HANDHELD
+ || target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
+ if (secondaryTarget == normalized) return;
+ secondaryTarget = normalized;
+ secondaryDetectionAt = 0;
+ rebindSecondaryDisplay();
+ }
+
+ private void refreshDualScreenDisplayMode() {
+ int mode = Settings.System.getInt(
+ getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
+ if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
+ dualScreenDisplayMode = mode;
+ }
+
private void registerSecondaryDisplayListener() {
- if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
- SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
- if (monitor.register()) secondaryDisplayMonitor = monitor;
+ if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
+ SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
+ if (monitor.register()) secondaryDisplayMonitor = monitor;
+ }
+ if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
+ getContentResolver().registerContentObserver(
+ Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
+ dualScreenModeObserver);
+ dualScreenModeObserverRegistered = true;
+ }
}
private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister();
+ if (dualScreenModeObserverRegistered) {
+ getContentResolver().unregisterContentObserver(dualScreenModeObserver);
+ dualScreenModeObserverRegistered = false;
+ }
}
- private static void refreshSecondaryDisplay() {
- GameActivity self = (GameActivity) mSingleton;
- if (self == null || !secondaryEnabled) return;
- SecondaryPresentation current = secondaryPresentation;
- Display display = current == null ? null : current.getDisplay();
+ private static boolean secondaryOutputIsPreferred(GameActivity self) {
+ Display preferred = findSecondaryDisplay(self, false);
+ if (preferred == null) return false;
+ SecondaryPresentation presentation = secondaryPresentation;
+ Display display = presentation == null
+ ? null : presentation.getDisplay();
+ if (display == null) {
+ SecondaryActivity activity = secondaryActivity;
+ display = activity == null ? null : getActivityDisplay(activity);
+ }
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
- if (current == null) {
- setupSecondaryDisplay();
- } else if (display == null || monitor == null
- || !monitor.hasDisplay(display.getDisplayId())) {
+ if (display == null || (monitor != null
+ && !monitor.hasDisplay(display.getDisplayId()))) return false;
+ return display.getDisplayId() == preferred.getDisplayId();
+ }
+
+ private static void rebindSecondaryDisplay() {
+ GameActivity self = (GameActivity) mSingleton;
+ if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
+ self.runOnUiThread(() -> {
+ if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay();
setupSecondaryDisplay();
- }
+ });
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
- if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
+ if (self == null || !secondaryEnabled || secondaryPresentation != null
+ || secondaryActivity != null || secondaryActivityPending
+ || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
try {
Display chosen = findSecondaryDisplay(self, true);
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
+ if (!isPresentationDisplay(chosen)) {
+ if (android.os.Build.VERSION.SDK_INT < 29) return;
+ secondaryActivityPending = true;
+ secondaryActivityTarget = chosen.getDisplayId();
+ Intent intent = new Intent(self, SecondaryActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
+ options.setLaunchDisplayId(secondaryActivityTarget);
+ self.startActivity(intent, options.toBundle());
+ final int requestedDisplay = secondaryActivityTarget;
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ if (secondaryActivityPending
+ && secondaryActivityTarget == requestedDisplay) {
+ secondaryActivityPending = false;
+ secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
+ }
+ }, 1000);
+ return;
+ }
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
+ p.setOnDismissListener(dialog -> {
+ if (secondaryPresentation == p) {
+ secondaryPresentation = null;
+ rebindSecondaryDisplay();
+ }
+ });
p.show();
secondaryPresentation = p;
+ secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) {
if (secondaryFrame != null) {
p.setBackground(secondaryBackground);
@@ -1559,39 +1663,77 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
- secondaryPresentation = null;
+ secondaryActivityPending = false;
+ secondaryActivityTarget = Display.INVALID_DISPLAY;
+ secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
+ teardownSecondaryDisplay();
}
}
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
- if (dm == null) return null;
- Display chosen = null;
- for (Display d : dm.getDisplays()) {
+ if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
+ Display gameDisplay = getActivityDisplay(self);
+ int gameDisplayId = gameDisplay == null
+ ? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
+ Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
+ boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
+ && gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
+ Display external = null;
+ Display[] presentations = dm.getDisplays(
+ android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
+ for (Display d : presentations) {
if (logDisplays) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId()
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y);
}
- if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) chosen = d;
+ if (external == null && d.getDisplayId() != gameDisplayId
+ && isDisplayUsable(d)) external = d;
}
- if (chosen == null) {
- Display[] presentations =
- dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
- if (presentations != null && presentations.length > 0) chosen = presentations[0];
+ if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
+ if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
+ return handheldAvailable ? handheld : external;
+ }
+
+ private static Display getActivityDisplay(android.app.Activity activity) {
+ return android.os.Build.VERSION.SDK_INT >= 30
+ ? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
+ }
+
+ private static boolean isPresentationDisplay(Display display) {
+ if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
+ return android.os.Build.VERSION.SDK_INT < 20
+ || (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
+ }
+
+ private static boolean isDisplayUsable(Display display) {
+ if (display == null) return false;
+ if (android.os.Build.VERSION.SDK_INT >= 20
+ && display.getState() == Display.STATE_OFF) return false;
+ if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
+ return false;
}
- return chosen;
+ return dualScreenDisplayMode != 2
+ || display.getDisplayId() != Display.DEFAULT_DISPLAY;
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
+ SecondaryActivity a = secondaryActivity;
+ secondaryActivity = null;
+ secondaryActivityPending = false;
+ secondaryActivityTarget = Display.INVALID_DISPLAY;
synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
+ if (a != null) {
+ try { a.finish(); } catch (Throwable t) {}
+ }
}
@android.annotation.TargetApi(17)
@@ -1620,7 +1762,7 @@ public class GameActivity extends SDLActivity {
private void changed() {
secondaryDetectionAt = 0;
- refreshSecondaryDisplay();
+ rebindSecondaryDisplay();
}
@Override public void onDisplayAdded(int displayId) { changed(); }
@@ -1630,14 +1772,15 @@ public class GameActivity extends SDLActivity {
@Keep
public static boolean hasSecondaryDisplay() {
- return secondaryPresentation != null;
+ return secondaryPresentation != null || secondaryActivity != null;
}
@Keep
public static boolean hasSecondaryDisplayCandidate() {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
- if (secondaryPresentation != null) return true;
+ if (secondaryPresentation != null || secondaryActivity != null) return true;
+ self.refreshDualScreenDisplayMode();
long now = android.os.SystemClock.uptimeMillis();
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
return secondaryDetected;
@@ -1667,10 +1810,16 @@ public class GameActivity extends SDLActivity {
secondaryBackground = backgroundColor;
secondaryFrameCover = cover;
SecondaryPresentation p = secondaryPresentation;
- if (p == null) return false;
+ SecondaryActivity a = secondaryActivity;
+ if (p == null && a == null) return false;
try {
- p.setBackground(backgroundColor);
- p.updateFrame(rgba, width, height, cover);
+ if (p != null) {
+ p.setBackground(backgroundColor);
+ p.updateFrame(rgba, width, height, cover);
+ } else {
+ a.setBackground(backgroundColor);
+ a.updateFrame(rgba, width, height, cover);
+ }
return true;
} catch (Throwable t) {
GameActivity self = (GameActivity) mSingleton;
@@ -1686,8 +1835,10 @@ public class GameActivity extends SDLActivity {
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
- if (p != null && buf != null && w > 0 && h > 0) {
- p.updateFrame(buf, w, h);
+ SecondaryActivity a = secondaryActivity;
+ if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
+ if (p != null) p.updateFrame(buf, w, h);
+ else a.updateFrame(buf, w, h);
}
}
@@ -1698,6 +1849,102 @@ public class GameActivity extends SDLActivity {
}
}
+ private static void applySecondaryImmersive(android.view.Window w) {
+ if (w == null) return;
+ if (android.os.Build.VERSION.SDK_INT >= 30) {
+ w.setDecorFitsSystemWindows(false);
+ android.view.WindowInsetsController c = w.getInsetsController();
+ if (c != null) {
+ c.hide(android.view.WindowInsets.Type.systemBars());
+ c.setSystemBarsBehavior(
+ android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ }
+ } else {
+ w.getDecorView().setSystemUiVisibility(
+ android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
+ | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
+ }
+ }
+
+ public static class SecondaryActivity extends android.app.Activity {
+ private FrameView frameView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Display display = getActivityDisplay(this);
+ if (!secondaryEnabled || display == null
+ || display.getDisplayId() != secondaryActivityTarget) {
+ secondaryActivityPending = false;
+ secondaryActivityTarget = Display.INVALID_DISPLAY;
+ secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
+ finish();
+ return;
+ }
+ frameView = new FrameView(this);
+ android.view.Window w = getWindow();
+ w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
+ setContentView(frameView);
+ applySecondaryImmersive(w);
+ secondaryActivity = this;
+ secondaryActivityPending = false;
+ secondaryRetryAfter = 0;
+ synchronized (secondaryFrameLock) {
+ if (secondaryFrame != null) {
+ setBackground(secondaryBackground);
+ updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
+ secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
+ }
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (secondaryActivity == this) secondaryActivity = null;
+ super.onDestroy();
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus) applySecondaryImmersive(getWindow());
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(android.view.KeyEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchGenericMotionEvent(event)
+ : super.dispatchGenericMotionEvent(event);
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
+ frameView.updateFrame(buf, w, h);
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
+ frameView.updateFrame(buf, w, h, cover);
+ }
+
+ void setBackground(int color) {
+ frameView.setFrameBackground(color);
+ }
+ }
+
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
@@ -1731,26 +1978,23 @@ public class GameActivity extends SDLActivity {
if (hasFocus) applyImmersive();
}
+ @Override
+ public boolean dispatchKeyEvent(android.view.KeyEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchGenericMotionEvent(event)
+ : super.dispatchGenericMotionEvent(event);
+ }
+
private void applyImmersive() {
- android.view.Window w = getWindow();
- if (w == null) return;
- if (android.os.Build.VERSION.SDK_INT >= 30) {
- w.setDecorFitsSystemWindows(false);
- android.view.WindowInsetsController c = w.getInsetsController();
- if (c != null) {
- c.hide(android.view.WindowInsets.Type.systemBars());
- c.setSystemBarsBehavior(
- android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
- }
- } else {
- w.getDecorView().setSystemUiVisibility(
- android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
- | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
- | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
- | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
- | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
- | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
- }
+ applySecondaryImmersive(getWindow());
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua
index 3a3a958f..eeda7750 100644
--- a/src/render/SecondScreen.lua
+++ b/src/render/SecondScreen.lua
@@ -7,6 +7,7 @@ local C = nil
local ffi = nil
local desktop = nil
local nativePresent = false
+local nativeTarget = false
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -25,6 +26,7 @@ do
int love_android_secondary_detected();
int love_android_present_secondary(const void *rgba, int w, int h,
unsigned int background, int cover);
+ void love_android_secondary_target(int target);
const char *love_android_poll_secondary_touch();
]])
local okLib, lib = pcall(ffi.load, "love")
@@ -45,8 +47,12 @@ do
local okPresent, present = pcall(function()
return C.love_android_present_secondary
end)
+ local okTarget, target = pcall(function()
+ return C.love_android_secondary_target
+ end)
nativePresent = okDetected and detected ~= nil
and okPresent and present ~= nil
+ nativeTarget = okTarget and target ~= nil
end
end
end
@@ -87,6 +93,15 @@ function SecondScreen.push(imageData, w, h, background, preference)
end
if not C or not imageData then return false end
if nativePresent and (background ~= nil or preference ~= nil) then
+ if nativeTarget then
+ local target = 0
+ if preference == "handheld" or preference == "handheld:cover" then
+ target = 1
+ elseif preference == "secondary" or preference == "secondary:cover" then
+ target = 2
+ end
+ pcall(C.love_android_secondary_target, target)
+ end
local cover = type(preference) == "string"
and preference:sub(-6) == ":cover"
local ok, shown = pcall(C.love_android_present_secondary,
diff --git a/tests/engine/android_asymmetric_display.lua b/tests/engine/android_asymmetric_display.lua
new file mode 100644
index 00000000..36c02368
--- /dev/null
+++ b/tests/engine/android_asymmetric_display.lua
@@ -0,0 +1,35 @@
+local function read(path)
+ local file = assert(io.open(path, "rb"))
+ local source = file:read("*a")
+ file:close()
+ return source
+end
+
+local function check(value, message)
+ if not value then error(message, 2) end
+end
+
+local java = read(
+ "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
+local manifest = read("mobile/android/app/src/main/AndroidManifest.xml")
+
+check(manifest:find("android.allow_multiple_resumed_activities", 1, true)
+ and manifest:find("GameActivity$SecondaryActivity", 1, true)
+ and manifest:find('android:exported="false"', 1, true),
+ "the private companion Activity opts into Android multi-display resume")
+check(java:find("android.os.Build.VERSION.SDK_INT < 29", 1, true)
+ and java:find("options.setLaunchDisplayId", 1, true),
+ "the primary-display fallback is restricted to Android 10+")
+check(java:find("SECONDARY_TARGET_HANDHELD", 1, true)
+ and java:find("SECONDARY_TARGET_EXTERNAL", 1, true)
+ and java:find("handheldAvailable ? handheld : external", 1, true),
+ "routing hints retain a safe available-display fallback")
+check(java:find("dualScreenDisplayMode != %-1")
+ and java:find("AYN_SECOND_SCREEN", 1, true)
+ and java:find("dualScreenModeObserverRegistered", 1, true),
+ "the optional AYN state is guarded and lifecycle-bound")
+check(java:find("activity.dispatchKeyEvent", 1, true)
+ and java:find("activity.dispatchGenericMotionEvent", 1, true),
+ "companion windows forward controller input to the game Activity")
+
+print("android asymmetric display routing: ok")
diff --git a/tests/engine/android_secondary_present.lua b/tests/engine/android_secondary_present.lua
index ec6ffc5f..d081410e 100644
--- a/tests/engine/android_secondary_present.lua
+++ b/tests/engine/android_secondary_present.lua
@@ -30,7 +30,8 @@ check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true)
"FrameView supports cover and pixel-friendly contain fits")
check(cpp:find("love_android_secondary_detected", 1, true)
and cpp:find("love_android_present_secondary", 1, true)
+ and cpp:find("love_android_secondary_target", 1, true)
and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true),
- "JNI exports the optional detected and presentation calls")
+ "JNI exports the optional detected, routing, and presentation calls")
print("android secondary presentation: ok")
diff --git a/tests/engine/second_screen_present.lua b/tests/engine/second_screen_present.lua
index 7f4643b6..dafb7ac8 100644
--- a/tests/engine/second_screen_present.lua
+++ b/tests/engine/second_screen_present.lua
@@ -14,6 +14,7 @@ local C = {
calls.push = { ptr, w, h }
end,
love_android_secondary_enable = function(on) calls.enabled = on end,
+ love_android_secondary_target = function(target) calls.target = target end,
love_android_secondary_detected = function() return 1 end,
love_android_present_secondary = function(ptr, w, h, background, cover)
calls.present = { ptr, w, h, background, cover }
@@ -44,6 +45,10 @@ T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary:cover"), true,
"extended Android presentation accepts frame metadata")
T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 },
"cover and RGB background reach the native bridge")
+T.eq(calls.target, 2, "secondary routing reaches the optional native bridge")
+T.eq(SecondScreen.push(image, 160, 144, 0x112233, "handheld"), true,
+ "handheld routing remains a contain presentation")
+T.eq(calls.target, 1, "handheld routing reaches the optional native bridge")
T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true,
"contain presentation remains available")
T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 },
@@ -52,6 +57,7 @@ T.eq(SecondScreen.push(image, 160, 144, nil, "secondary:cover"), true,
"a fit preference can request extended presentation by itself")
T.same(calls.present, { "pixels", 160, 144, 0, 1 },
"preference-only presentation defaults to a black background")
+T.eq(calls.target, 2, "a suffixed route keeps its target")
T.eq(SecondScreen.push(image, 160, 144), true,
"the original push ABI remains available")
T.same(calls.push, { "pixels", 160, 144 },