Merge pull request #1458 from AverageConsumer/codex/android-asymmetric-display-routing

This commit is contained in:
bryanthaboi
2026-08-16 22:00:33 -04:00
committed by GitHub
8 changed files with 384 additions and 53 deletions
+3
View File
@@ -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` the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
arguments request an extended presentation; a preference ending in `:cover` arguments request an extended presentation; a preference ending in `:cover`
fills and crops the target, while other values preserve the whole frame. 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 `pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
coordinates, or `nil`. coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens, This is what lets a mod lay the two passes out as two stacked Game Boy screens,
@@ -31,6 +31,9 @@
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/love" android:icon="@drawable/love"
android:label="${NAME}" > android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<activity <activity
android:name="org.love2d.android.GameActivity" android:name="org.love2d.android.GameActivity"
android:exported="true" android:exported="true"
@@ -49,5 +52,15 @@
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="${ORIENTATION}"
android:taskAffinity="${applicationId}.secondary"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application> </application>
</manifest> </manifest>
@@ -1229,6 +1229,20 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity); 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"))) extern "C" __attribute__((visibility("default")))
int love_android_secondary_detected() int love_android_secondary_detected()
{ {
@@ -60,6 +60,7 @@ import android.os.Environment;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.Vibrator; import android.os.Vibrator;
import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
import android.view.*; import android.view.*;
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
onHostResume(); onHostResume();
refreshDualScreenDisplayMode();
if (secondaryEnabled) registerSecondaryDisplayListener(); if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay(); 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 * SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no * (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 // Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_* // physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp. // 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 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 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 byte[] secondaryFrame;
private static volatile int secondaryFrameWidth; private static volatile int secondaryFrameWidth;
private static volatile int secondaryFrameHeight; private static volatile int secondaryFrameHeight;
@@ -1487,6 +1514,14 @@ public class GameActivity extends SDLActivity {
private static volatile long secondaryDetectionAt; private static volatile long secondaryDetectionAt;
private static volatile boolean secondaryDetected; private static volatile boolean secondaryDetected;
private SecondaryDisplayMonitor secondaryDisplayMonitor; 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 int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches = private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>(); new java.util.ArrayDeque<>();
@@ -1499,56 +1534,125 @@ public class GameActivity extends SDLActivity {
self.runOnUiThread(new Runnable() { self.runOnUiThread(new Runnable() {
@Override public void run() { @Override public void run() {
if (on) { if (on) {
self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener(); self.registerSecondaryDisplayListener();
setupSecondaryDisplay(); rebindSecondaryDisplay();
} else { } else {
self.unregisterSecondaryDisplayListener(); self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay(); teardownSecondaryDisplay();
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) { secondaryFrame = null; } 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() { private void registerSecondaryDisplayListener() {
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return; if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this); SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
if (monitor.register()) secondaryDisplayMonitor = monitor; 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() { private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor; SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null; secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister(); if (monitor != null) monitor.unregister();
if (dualScreenModeObserverRegistered) {
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
dualScreenModeObserverRegistered = false;
}
} }
private static void refreshSecondaryDisplay() { private static boolean secondaryOutputIsPreferred(GameActivity self) {
GameActivity self = (GameActivity) mSingleton; Display preferred = findSecondaryDisplay(self, false);
if (self == null || !secondaryEnabled) return; if (preferred == null) return false;
SecondaryPresentation current = secondaryPresentation; SecondaryPresentation presentation = secondaryPresentation;
Display display = current == null ? null : current.getDisplay(); Display display = presentation == null
? null : presentation.getDisplay();
if (display == null) {
SecondaryActivity activity = secondaryActivity;
display = activity == null ? null : getActivityDisplay(activity);
}
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor; SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
if (current == null) { if (display == null || (monitor != null
setupSecondaryDisplay(); && !monitor.hasDisplay(display.getDisplayId()))) return false;
} else if (display == null || monitor == null return display.getDisplayId() == preferred.getDisplayId();
|| !monitor.hasDisplay(display.getDisplayId())) { }
private static void rebindSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
self.runOnUiThread(() -> {
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay(); teardownSecondaryDisplay();
setupSecondaryDisplay(); setupSecondaryDisplay();
} });
} }
private static void setupSecondaryDisplay() { private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; 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 { try {
Display chosen = findSecondaryDisplay(self, true); Display chosen = findSecondaryDisplay(self, true);
if (chosen == null) { if (chosen == null) {
Log.d("GameActivity", "no secondary display found"); Log.d("GameActivity", "no secondary display found");
return; 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); SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.setOnDismissListener(dialog -> {
if (secondaryPresentation == p) {
secondaryPresentation = null;
rebindSecondaryDisplay();
}
});
p.show(); p.show();
secondaryPresentation = p; secondaryPresentation = p;
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) { synchronized (secondaryFrameLock) {
if (secondaryFrame != null) { if (secondaryFrame != null) {
p.setBackground(secondaryBackground); p.setBackground(secondaryBackground);
@@ -1559,39 +1663,77 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId()); Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) { } catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + 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) { private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
android.hardware.display.DisplayManager dm = android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return null; if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
Display chosen = null; Display gameDisplay = getActivityDisplay(self);
for (Display d : dm.getDisplays()) { 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) { if (logDisplays) {
android.graphics.Point size = new android.graphics.Point(); android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size); d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() Log.d("GameActivity", "display id=" + d.getDisplayId()
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y); + " 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) { if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
Display[] presentations = if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); return handheldAvailable ? handheld : external;
if (presentations != null && presentations.length > 0) chosen = presentations[0]; }
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() { private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation; SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null; secondaryPresentation = null;
SecondaryActivity a = secondaryActivity;
secondaryActivity = null;
secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
synchronized (secondaryTouches) { secondaryTouches.clear(); } synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) { if (p != null) {
try { p.dismiss(); } catch (Throwable t) {} try { p.dismiss(); } catch (Throwable t) {}
} }
if (a != null) {
try { a.finish(); } catch (Throwable t) {}
}
} }
@android.annotation.TargetApi(17) @android.annotation.TargetApi(17)
@@ -1620,7 +1762,7 @@ public class GameActivity extends SDLActivity {
private void changed() { private void changed() {
secondaryDetectionAt = 0; secondaryDetectionAt = 0;
refreshSecondaryDisplay(); rebindSecondaryDisplay();
} }
@Override public void onDisplayAdded(int displayId) { changed(); } @Override public void onDisplayAdded(int displayId) { changed(); }
@@ -1630,14 +1772,15 @@ public class GameActivity extends SDLActivity {
@Keep @Keep
public static boolean hasSecondaryDisplay() { public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null; return secondaryPresentation != null || secondaryActivity != null;
} }
@Keep @Keep
public static boolean hasSecondaryDisplayCandidate() { public static boolean hasSecondaryDisplayCandidate() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; 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(); long now = android.os.SystemClock.uptimeMillis();
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) { if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
return secondaryDetected; return secondaryDetected;
@@ -1667,10 +1810,16 @@ public class GameActivity extends SDLActivity {
secondaryBackground = backgroundColor; secondaryBackground = backgroundColor;
secondaryFrameCover = cover; secondaryFrameCover = cover;
SecondaryPresentation p = secondaryPresentation; SecondaryPresentation p = secondaryPresentation;
if (p == null) return false; SecondaryActivity a = secondaryActivity;
if (p == null && a == null) return false;
try { try {
p.setBackground(backgroundColor); if (p != null) {
p.updateFrame(rgba, width, height, cover); p.setBackground(backgroundColor);
p.updateFrame(rgba, width, height, cover);
} else {
a.setBackground(backgroundColor);
a.updateFrame(rgba, width, height, cover);
}
return true; return true;
} catch (Throwable t) { } catch (Throwable t) {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
@@ -1686,8 +1835,10 @@ public class GameActivity extends SDLActivity {
@Keep @Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) { public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation; SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) { SecondaryActivity a = secondaryActivity;
p.updateFrame(buf, w, h); 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 static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView; private final FrameView frameView;
@@ -1731,26 +1978,23 @@ public class GameActivity extends SDLActivity {
if (hasFocus) applyImmersive(); 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() { private void applyImmersive() {
android.view.Window w = getWindow(); applySecondaryImmersive(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);
}
} }
void updateFrame(java.nio.ByteBuffer buf, int w, int h) { void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
+15
View File
@@ -7,6 +7,7 @@ local C = nil
local ffi = nil local ffi = nil
local desktop = nil local desktop = nil
local nativePresent = false local nativePresent = false
local nativeTarget = false
local function log(msg) local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -25,6 +26,7 @@ do
int love_android_secondary_detected(); int love_android_secondary_detected();
int love_android_present_secondary(const void *rgba, int w, int h, int love_android_present_secondary(const void *rgba, int w, int h,
unsigned int background, int cover); unsigned int background, int cover);
void love_android_secondary_target(int target);
const char *love_android_poll_secondary_touch(); const char *love_android_poll_secondary_touch();
]]) ]])
local okLib, lib = pcall(ffi.load, "love") local okLib, lib = pcall(ffi.load, "love")
@@ -45,8 +47,12 @@ do
local okPresent, present = pcall(function() local okPresent, present = pcall(function()
return C.love_android_present_secondary return C.love_android_present_secondary
end) end)
local okTarget, target = pcall(function()
return C.love_android_secondary_target
end)
nativePresent = okDetected and detected ~= nil nativePresent = okDetected and detected ~= nil
and okPresent and present ~= nil and okPresent and present ~= nil
nativeTarget = okTarget and target ~= nil
end end
end end
end end
@@ -87,6 +93,15 @@ function SecondScreen.push(imageData, w, h, background, preference)
end end
if not C or not imageData then return false end if not C or not imageData then return false end
if nativePresent and (background ~= nil or preference ~= nil) then 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" local cover = type(preference) == "string"
and preference:sub(-6) == ":cover" and preference:sub(-6) == ":cover"
local ok, shown = pcall(C.love_android_present_secondary, local ok, shown = pcall(C.love_android_present_secondary,
@@ -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")
+2 -1
View File
@@ -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") "FrameView supports cover and pixel-friendly contain fits")
check(cpp:find("love_android_secondary_detected", 1, true) check(cpp:find("love_android_secondary_detected", 1, true)
and cpp:find("love_android_present_secondary", 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), 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") print("android secondary presentation: ok")
+6
View File
@@ -14,6 +14,7 @@ local C = {
calls.push = { ptr, w, h } calls.push = { ptr, w, h }
end, end,
love_android_secondary_enable = function(on) calls.enabled = on 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_secondary_detected = function() return 1 end,
love_android_present_secondary = function(ptr, w, h, background, cover) love_android_present_secondary = function(ptr, w, h, background, cover)
calls.present = { 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") "extended Android presentation accepts frame metadata")
T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 }, T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 },
"cover and RGB background reach the native bridge") "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, T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true,
"contain presentation remains available") "contain presentation remains available")
T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 }, 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") "a fit preference can request extended presentation by itself")
T.same(calls.present, { "pixels", 160, 144, 0, 1 }, T.same(calls.present, { "pixels", 160, 144, 0, 1 },
"preference-only presentation defaults to a black background") "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, T.eq(SecondScreen.push(image, 160, 144), true,
"the original push ABI remains available") "the original push ABI remains available")
T.same(calls.push, { "pixels", 160, 144 }, T.same(calls.push, { "pixels", 160, 144 },