diff --git a/.github/workflows/purchase-e2e.yml b/.github/workflows/purchase-e2e.yml index 79335c749e8..c5842f9d500 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 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..6ae2af4420a 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 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..a6a31e62763 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/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 280e4eb030f..decd63df52f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -90,6 +90,10 @@ private static int testTimeoutMs(BaseTest testClass) { && testClass instanceof LightweightPickerButtonsScreenshotTest) { return 45000; } + if (!"HTML5".equals(Display.getInstance().getPlatformName()) + && testClass instanceof ToastBarTopPositionScreenshotTest) { + return 45000; + } return "HTML5".equals(Display.getInstance().getPlatformName()) ? TEST_TIMEOUT_MS_HTML5 : TEST_TIMEOUT_MS_NATIVE; 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..8bdec27c281 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,12 +103,10 @@ 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) - && ((Component) tbc).isVisible() - && ((Component) tbc).getHeight() > 0; + boolean shown = isToastComponentShown(tbc); if (shown) { Component c = (Component) tbc; if (c.getY() == lastToastY && c.getHeight() == lastToastH) { @@ -122,13 +122,113 @@ 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, 0); 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 >= 12000) { + 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 && !isToastComponentShown(tbc)) { + showToast(); + awaitToastShown(parent, 0); + return; + } + UITimer.timer(250, false, parent, () -> awaitRenderedToastFrame(parent, waitedMs + 250)); + }); + } + + private boolean isToastComponentShown(Object tbc) { + return (tbc instanceof Component) + && ((Component) tbc).isVisible() + && ((Component) tbc).getHeight() > 0; + } + + 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 / 2)); + int requiredDarkPixels = Math.max(1, (width * 3) / 5); + // macOS native text antialiasing leaves relatively few pure-white + // pixels in the toast text/icon, even in the committed golden. + int requiredBrightPixels = Math.max(16, width / 3); + int bestBandHeight = 0; + int bestBandBrightPixels = 0; + int currentBandHeight = 0; + int currentBandBrightPixels = 0; + for (int y = 0; y < maxY; y++) { + int darkPixels = 0; + int brightPixels = 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 (r > 180 && g > 180 && b > 180) { + brightPixels++; + } + } + if (darkPixels >= requiredDarkPixels) { + currentBandHeight++; + currentBandBrightPixels += brightPixels; + } else { + if (currentBandHeight > bestBandHeight) { + bestBandHeight = currentBandHeight; + bestBandBrightPixels = currentBandBrightPixels; + } + currentBandHeight = 0; + currentBandBrightPixels = 0; + } + } + if (currentBandHeight > bestBandHeight) { + bestBandHeight = currentBandHeight; + bestBandBrightPixels = currentBandBrightPixels; + } + return bestBandHeight > 0 && bestBandBrightPixels >= requiredBrightPixels; } } diff --git a/scripts/input-validation-app/README.adoc b/scripts/input-validation-app/README.adoc index c27929fd810..27439b17f6e 100644 --- a/scripts/input-validation-app/README.adoc +++ b/scripts/input-validation-app/README.adoc @@ -22,7 +22,7 @@ emitted when each gesture fires. == Suite -Each step waits up to 8 seconds for its expected event, then auto-advances +Each step waits up to 15 seconds for its expected event, then auto-advances on success or timeout. The XCUITest driver issues the actual OS input at the expected time. diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java index 1b58d95cef4..ce80755e55a 100644 --- a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java @@ -24,7 +24,7 @@ /// auto-advances on either success or timeout so a broken gesture fails fast /// without blocking the rest of the suite. public final class GestureSuite { - private static final long DEFAULT_STEP_TIMEOUT_MS = 8000L; + private static final long DEFAULT_STEP_TIMEOUT_MS = 15000L; private static final long SUITE_EXIT_DELAY_MS = 1500L; private final GestureStep[] steps; @@ -32,6 +32,7 @@ public final class GestureSuite { private final Label statusLabel; private final Container targetArea; private int index = -1; + private int activeStepToken; private UITimer activeTimeout; public GestureSuite() { @@ -66,6 +67,7 @@ private void advance() { finishSuite(); return; } + final int stepToken = ++this.activeStepToken; final GestureStep step = this.steps[this.index]; this.statusLabel.setText("Step " + (this.index + 1) + "/" + this.steps.length + ": " + step.name()); @@ -73,6 +75,11 @@ private void advance() { step.install(this.targetArea, new GestureStep.Callback() { @Override public void onDetected(String details) { + if (stepToken != activeStepToken) { + return; + } + activeStepToken++; + cancelTimeout(); log("CN1IV:EVENT:" + step.name() + (details == null ? "" : ":" + details)); CN.callSerially(GestureSuite.this::advance); } @@ -80,6 +87,9 @@ public void onDetected(String details) { this.targetArea.revalidate(); log("CN1IV:READY:" + step.name()); this.activeTimeout = UITimer.timer((int) DEFAULT_STEP_TIMEOUT_MS, false, this.form, () -> { + if (stepToken != activeStepToken) { + return; + } log("CN1IV:TIMEOUT:" + step.name()); advance(); }); @@ -93,6 +103,7 @@ private void cancelTimeout() { } private void finishSuite() { + this.activeStepToken++; log("CN1IV:SUITE:FINISHED"); UITimer.timer((int) SUITE_EXIT_DELAY_MS, false, this.form, () -> { try {