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; + } +}