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
55 changes: 55 additions & 0 deletions decisions/2026-07-08-ios-icons-flattened-opaque.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# iOS app icons are flattened opaque; Android keeps transparency

- Date: 2026-07-08
- Status: accepted

## Context

App Store upload validation rejects an app whose 1024×1024 marketing icon
carries an alpha channel:

```
ITMS/altool error 90717: Invalid large app icon. The large app icon in the
asset catalog … can't be transparent or contain an alpha channel.
```

`MobDev.IconGenerator.write_ios_icons/2` simply `Image.thumbnail!`'d the source
into each iOS size, so a source PNG with transparency (a very common icon design
— a rounded badge on transparent corners) produced transparent iOS icons and
tripped 90717. The bundled fallback `mob_logo` iOS assets had the same problem.

Android must **not** be flattened: adaptive-icon foreground layers and legacy
launcher icons rely on transparency, and a flat background renders badly on some
launchers/versions. So the fix has to be platform-specific, not a blanket strip.

## Decision

Flatten **iOS only**. `write_ios_icons/3` now runs the source through
`flatten_for_ios/2` before resizing: if the source has an alpha channel it's
composited onto an opaque background via `Image.flatten!/2`; otherwise it's left
untouched. The background colour is the explicit `:background_color` option when
given, else sampled from the source with the same `extract_background_color/1`
the adaptive Android background uses — so the opaque iOS icon and the Android
adaptive background share one colour. `mix mob.icon` threads `--adaptive-bg` to
both platforms.

Android paths (`write_android_icons/2`, `write_adaptive_foregrounds/2`) are
unchanged and keep the source's transparency.

The bundled fallback `mob_logo` iOS-size PNGs (used when the `image` dep is
absent, so they can't be flattened at runtime) were pre-flattened opaque in
`priv/mob_logo/`. iOS and Android icon sizes are disjoint files there, so the
Android-size assets keep their transparency.

## Consequences

- Any mob app that ships a transparent source icon (or the default placeholder)
now produces an App-Store-valid opaque iOS icon set, while Android keeps its
adaptive transparency. Verified on Sloppy Joe (rounded-badge icon): the
rebuilt IPA passed App Store validation and reached TestFlight.
- Tests (`icon_generator_test.exs`): a transparent source yields alpha-free iOS
icons and alpha-bearing Android icons; an explicit `:background_color` fills
the flattened icon; an opaque source is left unflattened.
- The iOS icons are full-bleed opaque squares (iOS applies its own corner mask),
which is the platform-correct treatment — not the rounded-badge-with-margin
look that suits transparent Android/desktop contexts.
12 changes: 8 additions & 4 deletions lib/mix/tasks/mob.icon.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,16 @@ defmodule Mix.Tasks.Mob.Icon do
Mix.raise("No mix.exs found. Run mix mob.icon from your project root.")
end

# `--adaptive-bg` (if given) sets the flatten background for the opaque iOS
# icons too, so both platforms share one background colour; otherwise it's
# sampled from the source per platform.
icon_opts = if opts[:adaptive_bg], do: [background_color: opts[:adaptive_bg]], else: []

source =
case opts[:source] do
nil ->
Mix.shell().info("Generating random robot icon...")
MobDev.IconGenerator.generate_random(project_dir)
MobDev.IconGenerator.generate_random(project_dir, icon_opts)
Path.join(project_dir, "icon_source.png")

source ->
Expand All @@ -68,14 +73,13 @@ defmodule Mix.Tasks.Mob.Icon do
end

Mix.shell().info("Resizing icon from #{source}...")
MobDev.IconGenerator.generate_from_source(source, project_dir)
MobDev.IconGenerator.generate_from_source(source, project_dir, icon_opts)
source
end

if opts[:adaptive] do
Mix.shell().info("Generating adaptive Android icons...")
adaptive_opts = if opts[:adaptive_bg], do: [background_color: opts[:adaptive_bg]], else: []
MobDev.IconGenerator.generate_adaptive(source, project_dir, adaptive_opts)
MobDev.IconGenerator.generate_adaptive(source, project_dir, icon_opts)
end

Mix.shell().info("Icons written to #{project_dir}")
Expand Down
55 changes: 45 additions & 10 deletions lib/mob_dev/icon_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ defmodule MobDev.IconGenerator do

Returns `:ok` on success.
"""
@spec generate_random(output_dir :: String.t()) :: :ok
def generate_random(output_dir) do
@spec generate_random(output_dir :: String.t(), keyword()) :: :ok
def generate_random(output_dir, opts \\ []) do
if image_available?() do
renders_path = Path.join(output_dir, ".icon_renders")
File.mkdir_p!(renders_path)
Expand All @@ -90,7 +90,7 @@ defmodule MobDev.IconGenerator do
source_png = Path.join(output_dir, "icon_source.png")
Image.write!(avatar.image, source_png)

resize_for_platforms(source_png, output_dir)
resize_for_platforms(source_png, output_dir, opts)
else
Mix.shell().info("""
\nNote: the `image` dependency is not available so a random icon could not
Expand Down Expand Up @@ -121,12 +121,20 @@ defmodule MobDev.IconGenerator do
Resizes an existing image at `source_path` to all platform icon sizes,
writing them into `output_dir`.

## Options
* `:background_color` — hex string like `"#E8B53C"` used to flatten the
**iOS** icons (which must be opaque — Apple rejects any alpha channel on
the App Store marketing icon, error 90717). Android icons keep their
transparency. If absent, the colour is sampled from the source (same as
the adaptive-icon background), so the two platforms stay consistent.

Returns `:ok`.
"""
@spec generate_from_source(source_path :: String.t(), output_dir :: String.t()) :: :ok
def generate_from_source(source_path, output_dir) do
@spec generate_from_source(source_path :: String.t(), output_dir :: String.t(), keyword()) ::
:ok
def generate_from_source(source_path, output_dir, opts \\ []) do
if image_available?() do
resize_for_platforms(source_path, output_dir)
resize_for_platforms(source_path, output_dir, opts)
else
Mix.raise("""
The `image` dependency is required to generate icons from a source file.
Expand Down Expand Up @@ -269,10 +277,13 @@ defmodule MobDev.IconGenerator do
Code.ensure_loaded?(Image)
end

defp resize_for_platforms(source_png, output_dir) do
defp resize_for_platforms(source_png, output_dir, opts) do
source = Image.open!(source_png)
# Android keeps the source's transparency — adaptive-icon foregrounds and
# legacy launcher icons rely on it, and a flat background renders badly on
# some launchers. iOS is flattened opaque (see write_ios_icons).
write_android_icons(source, output_dir)
write_ios_icons(source, output_dir)
write_ios_icons(source, output_dir, opts)
:ok
end

Expand All @@ -293,18 +304,42 @@ defmodule MobDev.IconGenerator do
end)
end

defp write_ios_icons(source, output_dir) do
defp write_ios_icons(source, output_dir, opts) do
dest_dir = Path.join([output_dir, "ios", "Assets.xcassets", "AppIcon.appiconset"])
File.mkdir_p!(dest_dir)

# iOS icons must be opaque: Apple rejects any alpha channel on the App Store
# marketing icon (error 90717 "Invalid large app icon … can't be
# transparent"), and iOS applies its own corner mask, so a flat, full-bleed
# icon is correct. Flatten the source once, then resize the opaque result.
ios_source = flatten_for_ios(source, opts)

Enum.each(@ios_sizes, fn px ->
dest = Path.join(dest_dir, "icon_#{px}.png")
source |> Image.thumbnail!(px) |> Image.write!(dest)
ios_source |> Image.thumbnail!(px) |> Image.write!(dest)
end)

write_ios_contents_json(dest_dir)
end

# Flatten an alpha-bearing source onto an opaque background for iOS. Reuses the
# adaptive-icon background colour (explicit `:background_color` or sampled), so
# the iOS icon's fill matches the Android adaptive background. A source with no
# alpha is returned unchanged.
defp flatten_for_ios(source, opts) do
if Image.has_alpha?(source) do
bg =
case opts[:background_color] do
hex when is_binary(hex) -> normalise_hex!(hex)
_ -> extract_background_color(source)
end

Image.flatten!(source, background_color: bg)
else
source
end
end

defp write_ios_icons_from_priv(output_dir) do
dest_dir = Path.join([output_dir, "ios", "Assets.xcassets", "AppIcon.appiconset"])
File.mkdir_p!(dest_dir)
Expand Down
Binary file modified priv/mob_logo/1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/120.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/152.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/167.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/20.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/29.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/40.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/58.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/60.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/76.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/80.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified priv/mob_logo/87.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions test/mob_dev/icon_generator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,43 @@ defmodule MobDev.IconGeneratorTest do
assert Image.height(img) == px, "icon_#{px}: height #{Image.height(img)} != #{px}"
end)
end

test "from a transparent source: iOS icons are opaque, Android keeps alpha", %{tmp: tmp} do
# Apple rejects any alpha channel on the App Store icon (error 90717), but
# Android adaptive/legacy icons need transparency. A transparent source must
# therefore flatten on iOS and stay transparent on Android.
source = write_transparent_png(tmp)
assert Image.has_alpha?(Image.open!(source)), "fixture should have an alpha channel"

IconGenerator.generate_from_source(source, tmp)

Enum.each(IconGenerator.ios_sizes(), fn px ->
path = Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_#{px}.png")
refute Image.has_alpha?(Image.open!(path)), "icon_#{px}.png must be opaque (no alpha)"
end)

Enum.each(IconGenerator.android_sizes(), fn {bucket, _px} ->
path = Path.join(tmp, "android/app/src/main/res/#{bucket}/ic_launcher.png")
assert Image.has_alpha?(Image.open!(path)), "#{bucket} ic_launcher.png must keep alpha"
end)
end

test "an explicit :background_color fills the flattened iOS icon", %{tmp: tmp} do
# Fully-transparent source → the whole opaque icon becomes the background.
source = write_transparent_png(tmp)
IconGenerator.generate_from_source(source, tmp, background_color: "#00FF00")

img = Image.open!(Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png"))
refute Image.has_alpha?(img)
assert {:ok, [r, g, b | _]} = Image.get_pixel(img, 512, 512)
assert g > 200 and r < 80 and b < 80, "expected green fill, got #{inspect([r, g, b])}"
end

test "an opaque source is left unflattened (still writes iOS icons)", %{tmp: tmp} do
source = write_test_png(tmp)
assert :ok = IconGenerator.generate_from_source(source, tmp)
assert File.exists?(Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png"))
end
end

# ── generate_random/1 (integration — requires Avatarz + libvips) ─────────────
Expand Down Expand Up @@ -361,4 +398,12 @@ defmodule MobDev.IconGeneratorTest do
Image.new!(64, 64, color: color) |> Image.write!(path)
path
end

# A source PNG carrying an alpha channel (fully transparent), for exercising
# the iOS-flatten path.
defp write_transparent_png(dir) do
path = Path.join(dir, "transparent_source.png")
Image.new!(64, 64, color: :red) |> Image.add_alpha!(0) |> Image.write!(path)
path
end
end
Loading