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 },