diff --git a/.github/workflows/purchase-e2e.yml b/.github/workflows/purchase-e2e.yml index 79335c749e..c5842f9d50 100644 --- a/.github/workflows/purchase-e2e.yml +++ b/.github/workflows/purchase-e2e.yml @@ -272,6 +272,43 @@ 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 + 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_BIN" --install emulator --channel=0; then + exit 0 + fi + echo "Android emulator package install failed on attempt ${attempt}; retrying" + rm -rf "$ANDROID_SDK_ROOT/.temp" || true + sleep 10 + done + echo "::error::Unable to install Android emulator package after retries" + exit 1 # The emulator boot on GitHub-hosted runners intermittently dies before the # device ever appears ("device 'emulator-5554' not found" -> the runner # action's own boot timeout). One explicit retry recovers the infra lottery; diff --git a/Ports/iOSPort/nativeSources/CN1RenderingView.h b/Ports/iOSPort/nativeSources/CN1RenderingView.h index 3dfa2a88ad..50aa251354 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 92232c7acc..6ae2af4420 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,12 @@ - (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]; + } +#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 46412d804d..62f1aa4242 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 0966b7c1ba..b45d472feb 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 6097ad425c..a6a31e6276 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -519,6 +519,36 @@ -(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; + } + // 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; + } + 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 +562,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 +811,4 @@ -(void)layoutSubviews @end -#endif \ No newline at end of file +#endif 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 69cbeddb28..9da60518f9 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; } }