From 7db89fd6e9c362ef6f7abc1fcf2e66fd2c5b1191 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:13:51 +0300 Subject: [PATCH 1/5] Fix iOS Metal foreground framebuffer reset --- .../iOSPort/nativeSources/CN1RenderingView.h | 3 ++ .../nativeSources/CodenameOne_GLAppDelegate.m | 8 ++++ .../CodenameOne_GLViewController.m | 19 +++++++++- Ports/iOSPort/nativeSources/METALView.h | 11 +++++- Ports/iOSPort/nativeSources/METALView.m | 37 ++++++++++++++++++- 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1RenderingView.h b/Ports/iOSPort/nativeSources/CN1RenderingView.h index 3dfa2a88ad3..50aa2513549 100644 --- a/Ports/iOSPort/nativeSources/CN1RenderingView.h +++ b/Ports/iOSPort/nativeSources/CN1RenderingView.h @@ -48,6 +48,9 @@ - (void)keyboardDoneClicked; - (void)keyboardNextClicked; - (void)textFieldDidChange; +@optional +- (void)invalidateRetainedFramebuffer; +- (void)prepareRetainedFramebufferForDrawRect:(CGRect)rect displayWidth:(int)displayWidth displayHeight:(int)displayHeight; @end #endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 92232c7acc3..74745f4244e 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -43,6 +43,7 @@ #endif extern BOOL isIOS10(); +extern void repaintUI(); int pendingRemoteNotificationRegistrations = 0; BOOL isAppSuspended = NO; @@ -290,6 +291,13 @@ - (void)cn1ApplicationWillEnterForeground com_codename1_impl_ios_IOSImplementation_applicationWillEnterForeground__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); CodenameOne_GLViewController* vc = [CodenameOne_GLViewController instance]; if (vc != nil) { +#ifdef CN1_USE_METAL + id renderingView = [vc eaglView]; + if ([renderingView respondsToSelector:@selector(invalidateRetainedFramebuffer)]) { + [renderingView invalidateRetainedFramebuffer]; + repaintUI(); + } +#endif // Defer to the next runloop so UIKit can settle the view bounds // after the snapshot rotation. updateCanvas itself also // orientation-validates the bounds for an extra safety net under diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 46412d804d9..62f1aa42428 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -4299,7 +4299,22 @@ - (void)drawFrame:(CGRect)rect allowInactive:(BOOL)allowInactive return; } #endif - [[self eaglView] setFramebuffer]; + id renderingView = [self eaglView]; +#ifdef CN1_USE_METAL + BOOL hasScreenOps = NO; + if (currentTarget != nil) { + for (ExecutableOp *ex in currentTarget) { + if ([ex target] == nil) { + hasScreenOps = YES; + break; + } + } + } + if (hasScreenOps && [renderingView respondsToSelector:@selector(prepareRetainedFramebufferForDrawRect:displayWidth:displayHeight:)]) { + [renderingView prepareRetainedFramebufferForDrawRect:rect displayWidth:displayWidth displayHeight:displayHeight]; + } +#endif + [renderingView setFramebuffer]; GLErrorLog; BOOL drewContentOps = NO; if(currentTarget != nil) { @@ -4446,7 +4461,7 @@ - (void)drawFrame:(CGRect)rect allowInactive:(BOOL)allowInactive } GLErrorLog; - [[self eaglView] presentFramebuffer]; + [renderingView presentFramebuffer]; GLErrorLog; if (drewContentOps && !cn1LaunchPlaceholderDone) { // First frame with real content is on screen -- fade out the launch diff --git a/Ports/iOSPort/nativeSources/METALView.h b/Ports/iOSPort/nativeSources/METALView.h index 0966b7c1baa..b45d472feb4 100644 --- a/Ports/iOSPort/nativeSources/METALView.h +++ b/Ports/iOSPort/nativeSources/METALView.h @@ -50,6 +50,13 @@ // turn or by the next normal presentFramebuffer -- whichever lands first. // Read/written on the main thread only, so no synchronisation is needed. BOOL needsResizePresent; + + // The persistent screenTexture is private GPU storage. iOS may discard or + // leave it stale across app suspension, and CN1 can resume with only + // partial dirty-region flushes. Mark it invalid on foreground; the first + // full-screen repaint clears it before drawing fresh content. + BOOL retainedFramebufferInvalid; + BOOL clearRetainedFramebufferOnNextFrame; } @property (nonatomic, retain) id commandQueue; @property (nonatomic, retain) id commandBuffer; @@ -82,9 +89,11 @@ - (BOOL)presentFramebuffer; -(void)presentPreservedFrameIfNeeded; -(void)updateFrameBufferSize:(int)w h:(int)h; +-(void)invalidateRetainedFramebuffer; +-(void)prepareRetainedFramebufferForDrawRect:(CGRect)rect displayWidth:(int)displayWidth displayHeight:(int)displayHeight; -(void)textFieldDidChange; -(void) keyboardDoneClicked; -(void) keyboardNextClicked; -(void) addPeerComponent:(UIView*) view; @end -#endif \ No newline at end of file +#endif diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 6097ad425ce..44421b232f4 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -519,6 +519,33 @@ -(void)presentPreservedFrameIfNeeded { [presentCb commit]; } +-(void)invalidateRetainedFramebuffer { + retainedFramebufferInvalid = YES; + clearRetainedFramebufferOnNextFrame = NO; + // A preserved resize frame is also stale after a suspend/resume cycle. + needsResizePresent = NO; +} + +-(void)prepareRetainedFramebufferForDrawRect:(CGRect)rect displayWidth:(int)displayWidth displayHeight:(int)displayHeight { + if (!retainedFramebufferInvalid) { + return; + } + int targetWidth = displayWidth > 0 ? displayWidth : framebufferWidth; + int targetHeight = displayHeight > 0 ? displayHeight : framebufferHeight; + if (targetWidth <= 0 || targetHeight <= 0) { + return; + } + CGFloat minX = rect.origin.x; + CGFloat minY = rect.origin.y; + CGFloat maxX = rect.origin.x + rect.size.width; + CGFloat maxY = rect.origin.y + rect.size.height; + if (minX <= 0.0f && minY <= 0.0f && + maxX >= (CGFloat)targetWidth && maxY >= (CGFloat)targetHeight) { + clearRetainedFramebufferOnNextFrame = YES; + retainedFramebufferInvalid = NO; + } +} + -(void)createRenderPassDescriptor { if (self.screenTexture == nil) { self.renderPassDescriptor = nil; @@ -532,7 +559,13 @@ -(void)createRenderPassDescriptor { // which would wipe everything each frame) — CN1 only queues diff ops // per frame; the OpenGL path relies on its renderbuffer persisting. colorAttachment.texture = self.screenTexture; - colorAttachment.loadAction = MTLLoadActionLoad; + if (clearRetainedFramebufferOnNextFrame) { + colorAttachment.loadAction = MTLLoadActionClear; + colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); + clearRetainedFramebufferOnNextFrame = NO; + } else { + colorAttachment.loadAction = MTLLoadActionLoad; + } colorAttachment.storeAction = MTLStoreActionStore; // Attach the Stencil8 texture for polygon-shape clipping (#3921). // Cleared at the start of every frame and discarded at the end -- @@ -775,4 +808,4 @@ -(void)layoutSubviews @end -#endif \ No newline at end of file +#endif From 0a7dd2e5ccca94b63979acef2456c3182349d8f4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:20:13 +0300 Subject: [PATCH 2/5] Fix Metal retained framebuffer orientation guard --- Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m | 1 - Ports/iOSPort/nativeSources/METALView.m | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 74745f4244e..6ae2af4420a 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -295,7 +295,6 @@ - (void)cn1ApplicationWillEnterForeground id renderingView = [vc eaglView]; if ([renderingView respondsToSelector:@selector(invalidateRetainedFramebuffer)]) { [renderingView invalidateRetainedFramebuffer]; - repaintUI(); } #endif // Defer to the next runloop so UIKit can settle the view bounds diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 44421b232f4..a6a31e62763 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -530,8 +530,11 @@ -(void)prepareRetainedFramebufferForDrawRect:(CGRect)rect displayWidth:(int)disp if (!retainedFramebufferInvalid) { return; } - int targetWidth = displayWidth > 0 ? displayWidth : framebufferWidth; - int targetHeight = displayHeight > 0 ? displayHeight : framebufferHeight; + // During rotation, displayWidth/displayHeight can briefly lag the Metal + // drawable. Only consume the invalidation when the repaint covers the + // actual retained framebuffer we are about to load from. + int targetWidth = framebufferWidth > 0 ? framebufferWidth : displayWidth; + int targetHeight = framebufferHeight > 0 ? framebufferHeight : displayHeight; if (targetWidth <= 0 || targetHeight <= 0) { return; } From af3553f07cc1a87a9f174758c1f8156cadcef6cc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:01:50 +0300 Subject: [PATCH 3/5] Retry Android emulator package install in purchase CI --- .github/workflows/purchase-e2e.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/purchase-e2e.yml b/.github/workflows/purchase-e2e.yml index e87f9fc0f56..3aa4971c042 100644 --- a/.github/workflows/purchase-e2e.yml +++ b/.github/workflows/purchase-e2e.yml @@ -272,6 +272,20 @@ jobs: echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Preinstall Android emulator package + run: | + set -euo pipefail + printf 'y\n%.0s' {1..100} | sdkmanager --licenses >/dev/null || true + for attempt in 1 2 3; do + if sdkmanager --install emulator --channel=0; then + exit 0 + fi + echo "Android emulator package install failed on attempt ${attempt}; retrying" + rm -rf "${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}/.temp" || true + sleep 10 + done + echo "::error::Unable to install Android emulator package after retries" + exit 1 - name: Run Android purchase instrumentation test (targeted) uses: reactivecircus/android-emulator-runner@v2 with: From 480c813b2014779cdd83c80f066d8f1017683f4b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:26:07 +0300 Subject: [PATCH 4/5] Fix Android emulator preinstall sdkmanager lookup --- .github/workflows/purchase-e2e.yml | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/purchase-e2e.yml b/.github/workflows/purchase-e2e.yml index d00e9c203ca..c5842f9d500 100644 --- a/.github/workflows/purchase-e2e.yml +++ b/.github/workflows/purchase-e2e.yml @@ -275,13 +275,36 @@ jobs: - name: Preinstall Android emulator package run: | set -euo pipefail - printf 'y\n%.0s' {1..100} | sdkmanager --licenses >/dev/null || true + ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + if [ -z "$ANDROID_SDK_ROOT" ]; then + if [ -d "/usr/local/lib/android/sdk" ]; then + ANDROID_SDK_ROOT="/usr/local/lib/android/sdk" + elif [ -d "$HOME/Android/Sdk" ]; then + ANDROID_SDK_ROOT="$HOME/Android/Sdk" + fi + fi + if [ -z "$ANDROID_SDK_ROOT" ] || [ ! -d "$ANDROID_SDK_ROOT" ]; then + echo "::error::Android SDK root not found" + exit 1 + fi + export ANDROID_SDK_ROOT ANDROID_HOME="$ANDROID_SDK_ROOT" + + SDKMANAGER_BIN="$(command -v sdkmanager || true)" + if [ -z "$SDKMANAGER_BIN" ] && [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then + SDKMANAGER_BIN="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + fi + if [ -z "$SDKMANAGER_BIN" ]; then + echo "::error::sdkmanager not found under $ANDROID_SDK_ROOT" + exit 1 + fi + + printf 'y\n%.0s' {1..100} | "$SDKMANAGER_BIN" --licenses >/dev/null || true for attempt in 1 2 3; do - if sdkmanager --install emulator --channel=0; then + if "$SDKMANAGER_BIN" --install emulator --channel=0; then exit 0 fi echo "Android emulator package install failed on attempt ${attempt}; retrying" - rm -rf "${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}/.temp" || true + rm -rf "$ANDROID_SDK_ROOT/.temp" || true sleep 10 done echo "::error::Unable to install Android emulator package after retries" From 6b6fa1c2b2e8227ca9fba1bb78396b9fd8835e37 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:15:52 +0300 Subject: [PATCH 5/5] Stabilize ToastBar screenshot capture --- .../ToastBarTopPositionScreenshotTest.java | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToastBarTopPositionScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToastBarTopPositionScreenshotTest.java index 69cbeddb283..9da60518f99 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToastBarTopPositionScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToastBarTopPositionScreenshotTest.java @@ -6,6 +6,7 @@ import com.codename1.ui.Display; import com.codename1.ui.FontImage; import com.codename1.ui.Form; +import com.codename1.ui.Image; import com.codename1.ui.Label; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; @@ -49,9 +50,10 @@ protected void registerReadyCallback(Form parent, Runnable run) { // the form WITHOUT the toast, a run-to-run "toast present vs absent" flake that no // golden can reconcile (max_channel_delta 191). Rather than guess a timer, poll until // the toast component is actually laid out + visible on the current form, re-issuing - // the show if it never took, then hand off to the base settle+capture. Bounded so a - // genuine show failure degrades to a capture instead of hanging the suite. - awaitToastShown(parent, run, 0); + // the show if it never took, then capture only after a screenshot readback contains + // the rendered top bar. Bounded so a genuine show failure degrades to a capture + // instead of hanging the suite. + awaitToastShown(parent, 0); } private void showToast() { @@ -101,7 +103,7 @@ public void cleanup() { private int lastToastY = Integer.MIN_VALUE; private int lastToastH = Integer.MIN_VALUE; - private void awaitToastShown(final Form parent, final Runnable run, final int waitedMs) { + private void awaitToastShown(final Form parent, final int waitedMs) { Form f = Display.getInstance().getCurrent(); Object tbc = (f != null) ? f.getClientProperty("ToastBarComponent") : null; boolean shown = (tbc instanceof Component) @@ -122,13 +124,82 @@ private void awaitToastShown(final Form parent, final Runnable run, final int wa lastToastH = Integer.MIN_VALUE; } if ((shown && stableGeometryPolls >= 3) || waitedMs >= 15000) { - run.run(); + awaitRenderedToastFrame(parent, waitedMs); return; } if (!shown && waitedMs > 0 && (waitedMs % 3000) == 0) { // the show did not take (toast left at height 0) -> re-issue it showToast(); } - UITimer.timer(250, false, parent, () -> awaitToastShown(parent, run, waitedMs + 250)); + UITimer.timer(250, false, parent, () -> awaitToastShown(parent, waitedMs + 250)); + } + + private void awaitRenderedToastFrame(final Form parent, final int waitedMs) { + if (captureBlockedByOrientation("ToastBarTopPosition", waitedMs)) { + UITimer.timer(250, false, parent, () -> awaitRenderedToastFrame(parent, waitedMs + 250)); + return; + } + + Form f = Display.getInstance().getCurrent(); + Object tbc = (f != null) ? f.getClientProperty("ToastBarComponent") : null; + if (tbc instanceof Component) { + Component c = (Component) tbc; + c.repaint(); + if (c.getParent() != null) { + c.getParent().revalidate(); + c.getParent().repaint(); + } + } + parent.repaint(); + + markCaptureStarted(); + Display.getInstance().screenshot(screen -> { + if (screen == null) { + Cn1ssDeviceRunnerHelper.emitPlaceholderScreenshot("ToastBarTopPosition"); + done(); + return; + } + boolean rendered = containsRenderedToastBar(screen); + if (rendered || waitedMs >= 15000) { + if (!rendered) { + System.out.println("CN1SS:WARN:test=ToastBarTopPosition rendered toast not detected at capture bound"); + } + Cn1ssDeviceRunnerHelper.emitImage(screen, "ToastBarTopPosition", this::done); + return; + } + screen.dispose(); + if (waitedMs > 0 && (waitedMs % 3000) == 0) { + showToast(); + } + UITimer.timer(250, false, parent, () -> awaitRenderedToastFrame(parent, waitedMs + 250)); + }); + } + + private boolean containsRenderedToastBar(Image screen) { + int width = screen.getWidth(); + int height = screen.getHeight(); + if (width <= 0 || height <= 0) { + return false; + } + int[] rgb = screen.getRGB(); + int maxY = Math.min(height, Math.max(1, height / 3)); + int requiredDarkPixels = Math.max(1, (width * 3) / 5); + for (int y = 0; y < maxY; y++) { + int darkPixels = 0; + int rowOffset = y * width; + for (int x = 0; x < width; x++) { + int color = rgb[rowOffset + x]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + if (r < 96 && g < 96 && b < 96) { + darkPixels++; + } + } + if (darkPixels >= requiredDarkPixels) { + return true; + } + } + return false; } }