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
12 changes: 3 additions & 9 deletions src/launchpad/artifacts/apple/zipped_xcarchive.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,15 +341,9 @@ def _parse_asset_element(self, item: dict[str, Any], parent_path: Path) -> Asset
is_vector = item.get("vector", False)
filename = item.get("filename", "")

# Enhanced asset type handling
if asset_type == 1: # Standard PNG image
full_path = parent_path / f"{image_id}.png"
elif asset_type == 2: # JPEG image
full_path = parent_path / f"{image_id}.jpg"
elif asset_type == 3: # PDF/Vector image
full_path = parent_path / f"{image_id}.pdf"
elif asset_type == 4: # HEIF image
full_path = parent_path / f"{image_id}.heic"
if filename and Path(filename).suffix.lower() in {".png", ".jpg", ".jpeg", ".heic", ".heif"}:
potential_path = parent_path / f"{image_id}.png"
Comment thread
NicoHinderling marked this conversation as resolved.
full_path = potential_path if potential_path.exists() else None
else:
full_path = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def _resize_icon_for_analysis(self, img: Image.Image) -> Image.Image:
)

def _is_alternate_icon_file(self, file_info: FileInfo, alternate_icon_names: set[str]) -> bool:
return file_info.file_type.lower() in self.OPTIMIZABLE_FORMATS and any(
Path(file_info.path).stem.startswith(name) for name in alternate_icon_names
return (
file_info.file_type.lower() in self.OPTIMIZABLE_FORMATS
and Path(file_info.path).stem in alternate_icon_names
Copy link
Copy Markdown
Contributor Author

@NicoHinderling NicoHinderling Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont include images just because "AppIcon" is a prefix in the image's name

)
37 changes: 31 additions & 6 deletions src/launchpad/size/insights/apple/image_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from PIL import Image, ImageFile

from launchpad.size.insights.insight import Insight, InsightsInput
from launchpad.size.models.apple import AppleAppInfo
from launchpad.size.models.common import FileInfo
from launchpad.size.models.insights import (
ImageOptimizationInsightResult,
Expand Down Expand Up @@ -184,18 +185,42 @@ class ImageOptimizationInsight(BaseImageOptimizationInsight):
"""Analyse image optimisation opportunities in iOS apps."""

def _find_images(self, input: InsightsInput) -> List[FileInfo]:
app_info = input.app_info if isinstance(input.app_info, AppleAppInfo) else None
alternate_icon_names = (
set(app_info.alternate_icon_names) if app_info and app_info.alternate_icon_names else set()
)
primary_icon_name = app_info.primary_icon_name if app_info else None

images: List[FileInfo] = []
for fi in input.file_analysis.files:
if fi.file_type == "car":
images.extend(c for c in fi.children if self._is_optimizable_image_file(c))
elif self._is_optimizable_image_file(fi):
images.extend(
c
for c in fi.children
if self._is_optimizable_image_file(
c, alternate_icon_names, primary_icon_name, from_asset_catalog=True
)
)
elif self._is_optimizable_image_file(fi, alternate_icon_names, primary_icon_name, from_asset_catalog=False):
images.append(fi)
return images

def _is_optimizable_image_file(self, file_info: FileInfo) -> bool:
def _is_optimizable_image_file(
self,
file_info: FileInfo,
alternate_icon_names: set[str],
primary_icon_name: str | None,
from_asset_catalog: bool,
) -> bool:
if file_info.file_type.lower() not in self.OPTIMIZABLE_FORMATS:
return False
name = Path(file_info.path).name
if name.startswith(("AppIcon", "iMessage App Icon")):

if any(part.endswith(".stickerpack") for part in file_info.path.split("/")):
return False
return not any(part.endswith(".stickerpack") for part in file_info.path.split("/"))

# We can't guarantee that iMessage app's will have their icons specified in the Info.plist, so be conservative here
if not from_asset_catalog:
return not Path(file_info.path).name.startswith(("AppIcon", "iMessage App Icon"))

stem = Path(file_info.path).stem
return stem != primary_icon_name and stem not in alternate_icon_names
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't include any images that should be considered for the "alternative icon" insight for the "image optimization" insight

95 changes: 93 additions & 2 deletions tests/integration/test_image_optimization_insight.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,107 @@ def test_generates_optimization_results_for_large_images(
assert any("large_unoptimized.png" in path for path in optimizable_paths)
assert any("large_photo.jpg" in path for path in optimizable_paths)

def test_excludes_app_icons_and_sticker_packs(
def test_excludes_loose_app_icons_and_sticker_packs(
self, insight: ImageOptimizationInsight, insights_input: InsightsInput
) -> None:
"""Test that App Icons and sticker pack images are excluded from optimization."""
"""Test that loose AppIcon files and sticker pack images are excluded from optimization."""
result = insight.generate(insights_input)
assert result is not None
optimizable_paths = {f.file_path for f in result.optimizable_files}
# Loose AppIcon files should be excluded
assert not any("AppIcon" in path for path in optimizable_paths)
assert not any("stickerpack" in path for path in optimizable_paths)

def test_includes_appicon_display_variants_from_asset_catalogs(
self, insight: ImageOptimizationInsight, temp_images: Dict[str, Any]
) -> None:
"""Test that AppIcon display variants inside asset catalogs ARE included."""
# Create an asset catalog with an AppIcon display variant
car_path = temp_images["temp_dir"] / "Assets.car"

# Create an alternate icon display variant (should be included)
alternate_icon_display = temp_images["temp_dir"] / "AppIconFuzz_display.png"
icon_img = Image.new("RGB", (1024, 1024), (255, 0, 0))
icon_img.save(alternate_icon_display, format="PNG", optimize=False, compress_level=0)

# Create an actual alternate icon (should be excluded)
actual_icon = temp_images["temp_dir"] / "AppIconFuzz.png"
actual_icon_img = Image.new("RGB", (1024, 1024), (0, 255, 0))
actual_icon_img.save(actual_icon, format="PNG", optimize=False, compress_level=0)

# Create a mock asset catalog file entry
car_file_info = FileInfo(
path="Assets.car",
full_path=car_path,
size=1024,
file_type="car",
hash="test_hash",
treemap_type=TreemapType.ASSETS,
is_dir=False,
children=[
FileInfo(
path="Assets.car/AppIconFuzz_display/AppIconFuzz_display.png",
full_path=alternate_icon_display,
size=alternate_icon_display.stat().st_size,
file_type="png",
hash=calculate_file_hash(alternate_icon_display),
treemap_type=TreemapType.ASSETS,
is_dir=False,
children=[],
),
FileInfo(
path="Assets.car/AppIconFuzz/AppIconFuzz.png",
full_path=actual_icon,
size=actual_icon.stat().st_size,
file_type="png",
hash=calculate_file_hash(actual_icon),
treemap_type=TreemapType.ASSETS,
is_dir=False,
children=[],
),
],
)

file_analysis = FileAnalysis(files=[car_file_info], directories=[])

test_input = InsightsInput(
app_info=AppleAppInfo(
name="TestApp",
app_id="com.test.app",
version="1.0",
build="1",
executable="TestApp",
minimum_os_version="15.0",
supported_platforms=["iphoneos"],
sdk_version=None,
is_simulator=False,
codesigning_type=None,
profile_name=None,
profile_expiration_date=None,
certificate_expiration_date=None,
main_binary_uuid=None,
is_code_signature_valid=True,
code_signature_errors=[],
primary_icon_name=None,
alternate_icon_names=["AppIconFuzz"], # This is the actual icon
),
file_analysis=file_analysis,
binary_analysis=[],
treemap=None,
hermes_reports={},
)

result = insight.generate(test_input)
assert result is not None
assert len(result.optimizable_files) > 0

# The display variant should be included, but not the actual icon
optimizable_paths = {f.file_path for f in result.optimizable_files}
assert any("AppIconFuzz_display" in path for path in optimizable_paths), "Display variant should be included"
assert not any(path.endswith("AppIconFuzz/AppIconFuzz.png") for path in optimizable_paths), (
"Actual icon should be excluded"
)

def test_respects_minimum_savings_threshold(
self, insight: ImageOptimizationInsight, insights_input: InsightsInput
) -> None:
Expand Down
44 changes: 17 additions & 27 deletions tests/unit/test_alternate_icons_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def test_identifies_alternate_icons(self):
executable="TestApp",
minimum_os_version="14.0",
primary_icon_name="AppIcon",
alternate_icon_names=["DarkIcon", "LightIcon"],
alternate_icon_names=["DarkIcon-60@2x", "LightIcon-60@2x"],
)

# Simulate asset catalog with primary and alternate icons
Expand All @@ -129,7 +129,7 @@ def test_identifies_alternate_icons(self):
# Primary icon - should be excluded
FileInfo(
full_path=primary_icon_path,
path="Assets.car/AppIcon-60@2x",
path="Assets.car/AppIcon-60@2x.png",
size=primary_icon_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand All @@ -139,7 +139,7 @@ def test_identifies_alternate_icons(self):
# Alternate icons - should be included
FileInfo(
full_path=alt_icon1_path,
path="Assets.car/DarkIcon-60@2x",
Comment thread
NicoHinderling marked this conversation as resolved.
path="Assets.car/DarkIcon-60@2x.png",
size=alt_icon1_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand All @@ -148,7 +148,7 @@ def test_identifies_alternate_icons(self):
),
FileInfo(
full_path=alt_icon2_path,
path="Assets.car/LightIcon-60@2x",
path="Assets.car/LightIcon-60@2x.png",
size=alt_icon2_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand Down Expand Up @@ -176,9 +176,9 @@ def test_identifies_alternate_icons(self):

# Verify correct icons are included
paths = [f.file_path for f in result.optimizable_files]
assert any("DarkIcon" in p for p in paths)
assert any("LightIcon" in p for p in paths)
assert not any("AppIcon" in p for p in paths)
assert any("DarkIcon-60@2x" in p for p in paths)
assert any("LightIcon-60@2x" in p for p in paths)
assert not any("PrimaryIcon-60@2x" in p for p in paths)

finally:
# Clean up test files
Expand All @@ -199,8 +199,8 @@ def test_excludes_primary_icon(self):
app_id="com.test.app",
executable="TestApp",
minimum_os_version="14.0",
primary_icon_name="PrimaryIcon",
alternate_icon_names=["AlternateIcon"],
primary_icon_name="PrimaryIcon-60@2x",
alternate_icon_names=["AlternateIcon-60@2x"],
)

files = [
Expand All @@ -215,7 +215,7 @@ def test_excludes_primary_icon(self):
children=[
FileInfo(
full_path=primary_icon_path,
path="Assets.car/PrimaryIcon-60@2x",
path="Assets.car/PrimaryIcon-60@2x.png",
size=primary_icon_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand All @@ -224,7 +224,7 @@ def test_excludes_primary_icon(self):
),
FileInfo(
full_path=alt_icon_path,
path="Assets.car/AlternateIcon-60@2x",
path="Assets.car/AlternateIcon-60@2x.png",
size=alt_icon_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand Down Expand Up @@ -306,7 +306,7 @@ def test_minimum_savings_threshold(self):
executable="TestApp",
minimum_os_version="14.0",
primary_icon_name="AppIcon",
alternate_icon_names=["TinyIcon"],
alternate_icon_names=["TinyIcon-16"],
)

files = [
Expand All @@ -321,7 +321,7 @@ def test_minimum_savings_threshold(self):
children=[
FileInfo(
full_path=small_icon_path,
path="Assets.car/TinyIcon-16",
path="Assets.car/TinyIcon-16.png",
size=small_icon_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand Down Expand Up @@ -363,7 +363,7 @@ def test_icon_name_matching(self):
executable="TestApp",
minimum_os_version="14.0",
primary_icon_name="AppIcon",
alternate_icon_names=["CustomIcon"],
alternate_icon_names=["CustomIcon-60"],
)

files = [
Expand All @@ -376,30 +376,20 @@ def test_icon_name_matching(self):
hash="hash_car",
is_dir=False,
children=[
# Should match - starts with CustomIcon
# Should match - CustomIcon
FileInfo(
full_path=icon1_path,
path="Assets.car/CustomIcon-60",
path="Assets.car/CustomIcon-60.png",
size=icon1_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
hash="hash1",
is_dir=False,
),
# Should match - starts with CustomIcon
FileInfo(
full_path=icon2_path,
path="Assets.car/CustomIcon-60@2x",
size=icon2_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
hash="hash2",
is_dir=False,
),
# Should not match - different name
FileInfo(
full_path=icon3_path,
path="Assets.car/OtherIcon-60",
path="Assets.car/OtherIcon-60.png",
size=icon3_path.stat().st_size,
file_type="png",
treemap_type=TreemapType.ASSETS,
Expand Down
Loading