Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Ports/iOSPort/nativeSources/CN1Metalcompat.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
69 changes: 69 additions & 0 deletions Ports/iOSPort/nativeSources/CN1Metalcompat.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
}

Expand Down
11 changes: 11 additions & 0 deletions Ports/iOSPort/nativeSources/GLUIImage.m
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ -(void)setMtlMutableTexture:(id<MTLTexture>)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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/core/test/com/codename1/ui/MutableImageReadbackTest.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* @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;
}
}
Loading