From 6ef0dfced0029cb80ba868b9a1b0f5105d665cc3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:45:08 +0300 Subject: [PATCH] Fix mutable images losing their pixels across app suspension on Metal (#5153) On the iOS Metal port every mutable image is backed by an MTLStorageModePrivate texture. The system can discard the contents of those textures while the app is suspended in the background, so any mutable image that survives the suspension and is re-displayed without being redrawn samples uninitialized GPU memory on resume. The reported symptom is the FloatingActionButton's cached RoundBorder shadow showing a violet/garbage background after returning from a long pause, but the problem affects every mutable image the app holds, not just the FAB. Fix it at the source: keep a weak registry of every GLUIImage that owns a mutable texture and, on applicationWillResignActive (while the app is still active and GPU use is legal), read each one back into its CPU-side UIImage backing and drop the volatile texture. The texture is rebuilt transparently from that backing the next time the image is painted or sampled - CN1MetalEnsureMutableTexture and GLUIImage.getMTLTexture both re-seed from getImage - so the pixels survive the round trip. - CN1Metalcompat: weak mutable-image registry + CN1MetalBackupMutableImagesForSuspend - GLUIImage: register on mutable-texture assignment, unregister on dealloc - CodenameOne_GLAppDelegate: invoke the backup from cn1ApplicationWillResignActive (covers both the UIScene and legacy app-delegate paths) - tests: mutable-image draw -> read-back round-trip fidelity (the read-back the restore relies on) Co-Authored-By: Claude Opus 4.8 (1M context) --- Ports/iOSPort/nativeSources/CN1Metalcompat.h | 27 +++++++ Ports/iOSPort/nativeSources/CN1Metalcompat.m | 69 ++++++++++++++++ .../nativeSources/CodenameOne_GLAppDelegate.m | 12 +++ Ports/iOSPort/nativeSources/GLUIImage.m | 11 +++ .../ui/MutableImageReadbackTest.java | 78 +++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 tests/core/test/com/codename1/ui/MutableImageReadbackTest.java diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.h b/Ports/iOSPort/nativeSources/CN1Metalcompat.h index 7b28a9e6c2..c3811af5b4 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.h +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.h @@ -345,5 +345,32 @@ BOOL CN1MetalReadMutableImagePixels(GLUIImage *image, int *outARGB, // the mutable's pixels through CG / CIImage on Metal builds. UIImage * _Nullable CN1MetalReadMutableImageAsUIImage(GLUIImage *image); +// -------- Mutable-image suspend/resume backup (issue #5153) -------- +// +// MTLStorageModePrivate textures that back mutable images may have their +// contents discarded by the system while the app is suspended in the +// background. A mutable image that survives the suspension (anything cached +// and re-displayed without being redrawn -- e.g. the RoundBorder shadow used +// by FloatingActionButton) would then sample uninitialised GPU memory on +// resume, rendering as a violet/garbage fill. +// +// To make mutable images survive suspension we keep a weak registry of every +// GLUIImage that currently owns a mutable texture. The register/unregister +// hooks are called from GLUIImage as its mutable texture comes and goes. + +// Add/remove a GLUIImage from the live mutable-image registry. The registry +// holds weak references (no ownership); dealloc'd images drop out +// automatically. Safe to call from any thread. +void CN1MetalRegisterMutableImage(GLUIImage *image); +void CN1MetalUnregisterMutableImage(GLUIImage *image); + +// Read every registered mutable image back into its CPU-side UIImage backing +// and drop the volatile GPU texture. Called from applicationWillResignActive +// while the app is still active (so GPU use is legal). The texture is rebuilt +// from the backing on the next paint/sample after resume -- see +// CN1MetalEnsureMutableTexture and GLUIImage.getMTLTexture, both of which +// re-seed from getImage. Must be called on the main thread. +void CN1MetalBackupMutableImagesForSuspend(void); + #endif /* CN1_USE_METAL */ #endif /* CN1Metalcompat_h */ diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index 90e4722a88..265f766c36 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -1775,6 +1775,75 @@ static void cn1MetalReadbackFreeData(void * __unused info, const void *data, siz return out; } +// --------------- Mutable-image suspend/resume backup (issue #5153) --------------- +// +// Private-storage textures backing mutable images can have their contents +// discarded while the app is suspended, so a cached mutable image (e.g. the +// RoundBorder drop shadow under a FloatingActionButton) would sample garbage +// on resume and render as a violet fill. We keep a weak registry of every +// live mutable image and, on applicationWillResignActive, read each one back +// into its UIImage backing and drop the volatile texture. The texture is +// transparently rebuilt from that backing the next time the image is painted +// or sampled (CN1MetalEnsureMutableTexture / GLUIImage.getMTLTexture both +// re-seed from getImage), so the pixels survive the round trip. + +static NSHashTable *gMutableImageRegistry = nil; // weak refs, no ownership +static id gMutableImageRegistryToken = nil; // @synchronized lock token + +static void cn1EnsureMutableImageRegistry(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + gMutableImageRegistry = [[NSHashTable weakObjectsHashTable] retain]; + gMutableImageRegistryToken = [[NSObject alloc] init]; + }); +} + +void CN1MetalRegisterMutableImage(GLUIImage *image) { + if (image == nil) return; + cn1EnsureMutableImageRegistry(); + @synchronized (gMutableImageRegistryToken) { + [gMutableImageRegistry addObject:image]; + } +} + +void CN1MetalUnregisterMutableImage(GLUIImage *image) { + if (image == nil || gMutableImageRegistry == nil) return; + @synchronized (gMutableImageRegistryToken) { + [gMutableImageRegistry removeObject:image]; + } +} + +void CN1MetalBackupMutableImagesForSuspend(void) { + if (gMutableImageRegistry == nil) return; + NSArray *snapshot; + @synchronized (gMutableImageRegistryToken) { + // allObjects returns a strong-referencing array, so the images stay + // alive for the duration of the loop even though the table is weak. + // Iterating the snapshot (not the table) also makes the mutations + // below -- which can unregister images -- safe. + snapshot = [gMutableImageRegistry allObjects]; + } + for (GLUIImage *image in snapshot) { + if ([image mtlMutableTexture] == nil) { + continue; + } + // Read the current GPU pixels back into a UIImage *before* dropping + // the texture or its pending command buffer (the readback waits on + // that command buffer). + UIImage *backup = CN1MetalReadMutableImageAsUIImage(image); + if (backup == nil) { + // Readback failed -- keep the existing texture rather than lose + // the content outright; nothing better we can do here. + continue; + } + [image setMtlMutableCommandBuffer:nil]; + [image setMtlMutableTexture:nil width:0 height:0]; + // setImage: becomes the seed source for the lazy rebuild and also + // invalidates the read-only mtlTexture cache. + [image setImage:backup]; + } +} + // --------------- Memory-pressure cache release --------------- // // METALView observes UIApplicationDidReceiveMemoryWarning and calls diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index e9bb63bef3..e0c43341da 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -30,6 +30,9 @@ #import "EAGLView.h" #import "CodenameOne_GLViewController.h" #import "CN1TapGestureRecognizer.h" +#ifdef CN1_USE_METAL +#import "CN1Metalcompat.h" +#endif #include "com_codename1_impl_ios_IOSImplementation.h" #include "com_codename1_impl_ios_IOSNative.h" #include "com_codename1_push_PushContent.h" @@ -224,6 +227,15 @@ - (BOOL)cn1ContinueUserActivity:(NSUserActivity *)userActivity - (void)cn1ApplicationWillResignActive { +#ifdef CN1_USE_METAL + // Back up the pixels of every mutable image into CPU memory while the app + // is still active (GPU use is legal here, unlike didEnterBackground). The + // private-storage textures backing them can otherwise be discarded during + // suspension and sampled as garbage on resume -- the FloatingActionButton + // "violet background" artifact (issue #5153). The textures are rebuilt + // lazily from the backup the next time each image is painted. + CN1MetalBackupMutableImagesForSuspend(); +#endif com_codename1_impl_ios_IOSImplementation_applicationWillResignActive__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); } diff --git a/Ports/iOSPort/nativeSources/GLUIImage.m b/Ports/iOSPort/nativeSources/GLUIImage.m index 535298f99c..157c46c264 100644 --- a/Ports/iOSPort/nativeSources/GLUIImage.m +++ b/Ports/iOSPort/nativeSources/GLUIImage.m @@ -202,6 +202,13 @@ -(void)setMtlMutableTexture:(id)t width:(int)w height:(int)h { mtlMutableTexture = t; mtlMutableWidth = w; mtlMutableHeight = h; + // Track live mutable images so their pixels can be backed up before the + // app is suspended (issue #5153). Registering only on a non-nil texture + // keeps the registry to images that actually own GPU content; the weak + // table drops them automatically on dealloc. + if (t != nil) { + CN1MetalRegisterMutableImage(self); + } // Stale cached read-only texture: future getMTLTexture should sample // mtlMutableTexture instead of the UIImage-derived one. Release the // +1 retain transferred in by getMTLTexture's CN1MetalTextureFromUIImage @@ -256,6 +263,10 @@ -(void)dealloc { // leaks a GPU texture: the animation/transition test suite creates // 7 mutable images per test × ~17 tests, and the simulator runs out // of Metal device memory mid-suite, hanging the next test. + // Drop out of the suspend/resume backup registry (issue #5153). The weak + // table would zero this slot on its own, but unregistering explicitly + // keeps it tidy and avoids a stale slot lingering until the next compaction. + CN1MetalUnregisterMutableImage(self); [mtlTexture release]; mtlTexture = nil; [mtlMutableTexture release]; mtlMutableTexture = nil; // Same +1 retain ownership rule for the cached command buffer (the diff --git a/tests/core/test/com/codename1/ui/MutableImageReadbackTest.java b/tests/core/test/com/codename1/ui/MutableImageReadbackTest.java new file mode 100644 index 0000000000..75b4db286e --- /dev/null +++ b/tests/core/test/com/codename1/ui/MutableImageReadbackTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.ui; + +import com.codename1.testing.AbstractTest; + +/** + * Round-trip coverage for drawing into a mutable image and reading the pixels + * back out. This is the mechanism the iOS Metal port relies on to survive app + * suspension (issue #5153): on {@code applicationWillResignActive} every + * mutable image is read back into a CPU-side backing and its volatile GPU + * texture is dropped, then rebuilt from that backing on resume. If the + * draw -> read-back path is not pixel-faithful, the restore corrupts content, + * so this test pins that contract down. + * + *

On the JavaSE simulator this exercises the generic mutable-image path; on + * an iOS device build it exercises {@code CN1MetalReadMutableImagePixels} -- + * the exact read-back used by the suspend backup.

+ * + * @author Codename One + */ +public class MutableImageReadbackTest extends AbstractTest { + + @Override + public boolean shouldExecuteOnEDT() { + return true; + } + + @Override + public boolean runTest() throws Exception { + int w = 40, h = 40; + + // Draw a known two-colour pattern into a mutable image: a red field + // with a green square covering the top-left quadrant. + Image img = Image.createImage(w, h, 0xffff0000); + Graphics g = img.getGraphics(); + g.setColor(0x00ff00); + g.fillRect(0, 0, w / 2, h / 2); + + int[] rgb = img.getRGB(); + assertEqual(w * h, rgb.length, "getRGB returned an unexpected buffer size"); + + // Top-left quadrant must be the green we just painted; the opposite + // corner must remain the red fill. Compare on the RGB channels only -- + // the alpha byte of a fully opaque pixel is reported consistently by + // the platform but the colour channels are what the restore must + // preserve. + assertEqual(0x00ff00, rgb[pixel(5, 5, w)] & 0xffffff, + "Mutable image did not read back the painted green quadrant"); + assertEqual(0xff0000, rgb[pixel(w - 5, h - 5, w)] & 0xffffff, + "Mutable image did not read back the red fill outside the green quadrant"); + + // A second draw must accumulate on top of the existing pixels (the + // mutable image is not wiped between draws) and read back correctly -- + // this mirrors content being layered after a restore. + g.setColor(0x0000ff); + g.fillRect(w / 2, h / 2, w / 2, h / 2); + int[] rgb2 = img.getRGB(); + assertEqual(0x0000ff, rgb2[pixel(w - 5, h - 5, w)] & 0xffffff, + "Second draw into the mutable image did not read back"); + assertEqual(0x00ff00, rgb2[pixel(5, 5, w)] & 0xffffff, + "Earlier mutable-image content was lost after a subsequent draw"); + + return true; + } + + private static int pixel(int x, int y, int w) { + return y * w + x; + } +}