Debug Icon Watermark: Seven Bugs From One Build Script
Adding a red DEBUG ribbon to the app icon on debug builds hit every possible platform surprise: CgBI PNGs, adaptive icon bleed zones, Xcode script sandboxing, and Assets.car.
Why I wanted this
I regularly install both debug and release builds on the same device. Without a visual marker it's easy to accidentally test against a debug build with debug data, or assume I'm looking at a production issue when I'm actually on a dev build. A diagonal red "DEBUG" ribbon on the home-screen icon makes it immediately obvious which build is running.
What should have been a quick script turned into a multi bug hunt across both platforms.
Bug 1 (Android) — The missing task dependency
The first attempt wired the watermark generator task using the old sourceSets { getByName("debug") { res.srcDir(...) } } approach plus manual afterEvaluate { tasks.named(...) { dependsOn(...) } }. The build failed with an implicit dependency error: mapDebugSourceSetPaths read the generated icon folder without waiting for the task that writes it. Claude added a dependsOn for that one task.
Bug 2 (Android) — The same error, one task later
The next build failed the same way on processDebugNavigationResources. AGP 9 adds new consumers of the resource source set with each release, and with the old pattern each one needs a separate dependsOn or the build fails with an implicit dependency error.
The correct approach is the AGP Variant API, which wires all current and future consumers automatically:
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
val taskProvider = tasks.register(
"generateDebugWatermarkIcons",
GenerateDebugWatermarkIconsTask::class.java
) { /* configure inputs/outputs */ }
variant.sources.res?.addGeneratedSourceDirectory(
taskProvider,
GenerateDebugWatermarkIconsTask::outputDir
)
}
}
Bug 3 (Android) — The adaptive icon bleed zone
Android launchers composite the foreground and background adaptive icon layers and then clip them to a squircle or circle shape. The outer roughly 17% on each side — the "bleed zone" — is always clipped. A ribbon placed at the outer corner of the foreground PNG is invisible on the home screen even though it appears correctly in the APK and looks right in the source file.
ic_launcher_foreground.png (432×432)
┌─────────────────────────────────┐
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ ← bleed zone (clipped by launcher)
│▒▒▒▒┌───────────────────────┐▒▒▒▒│
│▒▒▒▒│ │▒▒▒▒│
│▒▒▒▒│ safe zone (72dp) │▒▒▒▒│
│▒▒▒▒│ icon artwork here │▒▒▒▒│
│▒▒▒▒│ │▒▒▒▒│
│▒▒▒▒└───────────────────────┘▒▒▒▒│
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ◥DEBUG◤ ▒▒│ ← ribbon is here, gets clipped
└─────────────────────────────────┘
Here is the same thing drawn from the real icon files. On the left are the icon layers as packed in the APK; the dashed circle is what the launcher keeps. On the right is what a Pixel draws, using the launcher's default circle shape. These are drawn from the files, not screenshots from the phone.
Before: the ribbon sat in the outer corner of the layer, which the launcher cuts off.
After: anchored at the corner of the safe zone, the ribbon reaches inside the circle.
Look for the white "DEBUG" text in both images: it isn't in the ribbon. One more bug turned up while Claude was drawing these images for this post. Claude's script rotates the text around the middle of the icon instead of around the text itself, so the word lands on the artwork below the centre, outside the ribbon. That one isn't fixed yet.
The script now calculates the safe zone and positions the ribbon anchor within it:
def overlay_ribbon(image_path, is_foreground: bool = False):
w, h = img.size
if is_foreground:
margin = w // 6 # 18/108 = 1/6 safe zone margin
eff_r = w - margin # anchor ribbon within the safe zone
eff_b = h - margin
else:
eff_r = w # flat icon — full canvas is visible
eff_b = h
After writing each PNG, the script samples a pixel inside where the ribbon should be and prints a warning if the red channel is too low — catching this class of positioning bug at build time rather than after installing on device.
Bug 4 (iOS) — Xcode CgBI PNGs break Pillow
Xcode's build system runs copypng on every PNG asset by default — for all configurations including Debug. This rewrites the PNG into Apple's proprietary CgBI format: the color channels are premultiplied and the byte order is swapped. Standard image libraries including Pillow cannot decompress CgBI and raise OSError: broken data stream.
The detection is simple — CgBI files have the bytes CgBI in the first 20 bytes of the file. The fix uses sips, Apple's own image tool that ships with macOS:
def _is_cgbi(path: Path) -> bool:
return b"CgBI" in path.read_bytes()[:20]
def _deoptimize_png(path: Path) -> None:
subprocess.run(
["sips", "-s", "format", "png", str(path), "--out", str(path)],
check=True, capture_output=True,
)
Check at the top of overlay_ribbon() and deoptimize before Pillow opens the file.
Bug 5 (iOS) — The wrong script path
The Xcode build phase ran the watermark script from $(SRCROOT)/../../scripts/add_debug_watermark.py. The actual location is $(SRCROOT)/../scripts/ — one directory level up from the Xcode project, not two. Python couldn't find the script, so Xcode stopped the build with "Command PhaseScriptExecution failed with a nonzero exit code".
Bug 6 (iOS) — ENABLE_USER_SCRIPT_SANDBOXING blocks file iteration
New Xcode projects set ENABLE_USER_SCRIPT_SANDBOXING to YES, and this one had it on in both configurations. Any run script that accesses files inside the $BUILT_PRODUCTS_DIR app bundle without declaring them as inputPaths or outputPaths is blocked silently — Path.glob() returns an empty list and Path.iterdir() raises PermissionError. The build failed again, this time with that PermissionError. Setting ENABLE_USER_SCRIPT_SANDBOXING = NO in the target's Build Settings fixed it.
Bug 7 (iOS) — Assets.car, not bundle root PNGs
The iOS home-screen icon does not come from the PNG files in the app bundle root. It comes from Assets.car — the compiled asset catalog. By the time the run script modifies PNGs in $BUILT_PRODUCTS_DIR, Xcode has already compiled Assets.xcassets into Assets.car. The modifications have no effect on what iOS displays.
The correct approach: create a separate AppIconDebug.appiconset with pre-watermarked source images, and set ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug in the Debug build configuration only. Xcode bakes the watermarked icons into Assets.car at compile time, and iOS reads them from there on install.
Where Claude Got It Wrong
Almost every bug in this session was introduced by Claude's initial implementation. The wrong path, the missing CgBI handling, no awareness of script sandboxing, the bleed zone miscalculation, the outdated sourceSets pattern, and the assumption that modifying bundle-root PNGs would affect the home-screen icon — all of these were Claude's errors. I had to work through each one, pushing back after each failed build or device test, before the final implementation was correct.
Learnings
- Xcode runs
copypngon all PNGs by default — always check for CgBI before passing to Pillow; usesipsto deoptimize - New Xcode projects sandbox run scripts — set
ENABLE_USER_SCRIPT_SANDBOXING = NOwhen the script needs undeclared bundle access - iOS home-screen icon reads from
Assets.car, not bundle root PNGs — useAppIconDebug.appiconset+ASSETCATALOG_COMPILER_APPICON_NAME - Android adaptive icon foreground: outer ~17% is clipped by launchers — position any overlay within the inner safe zone (
margin = w // 6) - Use AGP Variant API (
addGeneratedSourceDirectory) for generated res directories — the oldsourceSets + afterEvaluatepattern requires manualdependsOnfor every new AGP consumer
