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