Skip to content

Commit 2be5cc9

Browse files
Add "optimize alternative icons" insight handling (#417)
* Add Optimize alternative icons insight handling * trevor feedback * get rid of logger.error in image_optimization.py too
1 parent 7683d66 commit 2be5cc9

File tree

7 files changed

+624
-48
lines changed

7 files changed

+624
-48
lines changed

src/launchpad/artifacts/apple/zipped_xcarchive.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,32 @@ def get_plist(self) -> dict[str, Any]:
6868
except Exception as e:
6969
raise RuntimeError("Failed to parse Info.plist") from e
7070

71+
def get_icon_info(self) -> tuple[str | None, list[str]]:
72+
"""Extract primary and alternate icon names from Info.plist.
73+
74+
Returns:
75+
Tuple of (primary_icon_name, alternate_icon_names list)
76+
"""
77+
plist = self.get_plist()
78+
bundle_icons = plist.get("CFBundleIcons", {})
79+
80+
primary_icon_name: str | None = None
81+
alternate_icon_names: list[str] = []
82+
83+
primary_icon = bundle_icons.get("CFBundlePrimaryIcon", {})
84+
if isinstance(primary_icon, dict):
85+
primary_icon_name = primary_icon.get("CFBundleIconName")
86+
87+
alternate_icons = bundle_icons.get("CFBundleAlternateIcons", {})
88+
if isinstance(alternate_icons, dict):
89+
for icon_key, icon_data in alternate_icons.items():
90+
if isinstance(icon_data, dict):
91+
icon_name = icon_data.get("CFBundleIconName")
92+
if icon_name:
93+
alternate_icon_names.append(icon_name)
94+
95+
return primary_icon_name, alternate_icon_names
96+
7197
def generate_ipa(self, output_path: Path):
7298
"""Generate an IPA file
7399

src/launchpad/size/analyzers/apple.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from launchpad.parsers.apple.swift_symbol_type_aggregator import SwiftSymbolTypeAggregator
2424
from launchpad.size.constants import APPLE_FILESYSTEM_BLOCK_SIZE
2525
from launchpad.size.hermes.utils import make_hermes_reports
26+
from launchpad.size.insights.apple.alternate_icons_optimization import AlternateIconsOptimizationInsight
2627
from launchpad.size.insights.apple.image_optimization import ImageOptimizationInsight
2728
from launchpad.size.insights.apple.localized_strings import LocalizedStringsInsight
2829
from launchpad.size.insights.apple.localized_strings_minify import MinifyLocalizedStringsInsight
@@ -227,6 +228,9 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults:
227228
unnecessary_files=self._generate_insight_with_tracing(
228229
UnnecessaryFilesInsight, insights_input, "unnecessary_files"
229230
),
231+
alternate_icons_optimization=self._generate_insight_with_tracing(
232+
AlternateIconsOptimizationInsight, insights_input, "alternate_icons_optimization"
233+
),
230234
# TODO: enable audio/video compression insights once we handle ffmpeg
231235
# audio_compression=self._generate_insight_with_tracing(
232236
# AudioCompressionInsight, insights_input, "audio_compression"
@@ -293,6 +297,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
293297
is_code_signature_valid = False
294298
code_signature_errors = [str(e)]
295299

300+
primary_icon_name, alternate_icon_names = xcarchive.get_icon_info()
301+
296302
return AppleAppInfo(
297303
name=plist.get("CFBundleName", "Unknown"),
298304
app_id=plist.get("CFBundleIdentifier", "unknown.bundle.id"),
@@ -310,6 +316,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
310316
is_code_signature_valid=is_code_signature_valid,
311317
code_signature_errors=code_signature_errors,
312318
main_binary_uuid=xcarchive.get_main_binary_uuid(),
319+
primary_icon_name=primary_icon_name,
320+
alternate_icon_names=alternate_icon_names,
313321
)
314322

315323
def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]:
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
import io
4+
5+
from pathlib import Path
6+
from typing import List
7+
8+
from PIL import Image
9+
10+
from launchpad.size.insights.apple.image_optimization import BaseImageOptimizationInsight
11+
from launchpad.size.insights.insight import InsightsInput
12+
from launchpad.size.models.apple import AppleAppInfo
13+
from launchpad.size.models.common import FileInfo
14+
from launchpad.utils.logging import get_logger
15+
16+
logger = get_logger(__name__)
17+
18+
19+
class AlternateIconsOptimizationInsight(BaseImageOptimizationInsight):
20+
"""Analyze alternate app icon optimization opportunities in iOS apps.
21+
22+
Alternate app icons can be optimized without affecting the App Store listing since
23+
only the primary icon is displayed there. This insight identifies alternate icons
24+
that could be minified or converted to more efficient formats like HEIC.
25+
26+
Icons are resized to device display size (180px for iPhone 3x) and back to store
27+
size (1024px) before optimization, since they only need quality for homescreen display.
28+
"""
29+
30+
IPHONE_3X_ICON_SIZE = 180 # Largest icon size displayed on device
31+
APP_STORE_ICON_SIZE = 1024 # Standard App Store icon size
32+
33+
def _find_images(self, input: InsightsInput) -> List[FileInfo]:
34+
if not isinstance(input.app_info, AppleAppInfo):
35+
return []
36+
37+
if not input.app_info.alternate_icon_names:
38+
return []
39+
40+
alternate_icon_names = set(input.app_info.alternate_icon_names)
41+
car_files = [f for f in input.file_analysis.files if f.file_type == "car"]
42+
43+
images: List[FileInfo] = []
44+
for car_file in car_files:
45+
if not car_file.children or (len(car_file.children) == 1 and car_file.children[0].path.endswith("/Other")):
46+
logger.warning(
47+
"Asset catalog %s has no parsed children. ParsedAssets directory may be missing.", car_file.path
48+
)
49+
continue
50+
51+
for child in car_file.children:
52+
if self._is_alternate_icon_file(child, alternate_icon_names):
53+
images.append(child)
54+
55+
return list({img.path: img for img in images}.values())
56+
57+
def _preprocess_image(self, img: Image.Image, file_info: FileInfo) -> tuple[Image.Image, int, int]:
58+
resized = self._resize_icon_for_analysis(img)
59+
60+
fmt = img.format or "PNG"
61+
with io.BytesIO() as buf:
62+
resized.save(buf, format=fmt)
63+
resized_size = buf.tell()
64+
65+
baseline_savings = max(0, file_info.size - resized_size)
66+
return resized, resized_size, baseline_savings
67+
68+
def _resize_icon_for_analysis(self, img: Image.Image) -> Image.Image:
69+
return img.resize((self.IPHONE_3X_ICON_SIZE, self.IPHONE_3X_ICON_SIZE), Image.Resampling.LANCZOS).resize(
70+
(self.APP_STORE_ICON_SIZE, self.APP_STORE_ICON_SIZE), Image.Resampling.LANCZOS
71+
)
72+
73+
def _is_alternate_icon_file(self, file_info: FileInfo, alternate_icon_names: set[str]) -> bool:
74+
return file_info.file_type.lower() in self.OPTIMIZABLE_FORMATS and any(
75+
Path(file_info.path).stem.startswith(name) for name in alternate_icon_names
76+
)

src/launchpad/size/insights/apple/image_optimization.py

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import io
44
import logging
55

6+
from abc import ABC, abstractmethod
67
from concurrent.futures import ThreadPoolExecutor, as_completed
78
from dataclasses import dataclass
89
from pathlib import Path
9-
from typing import Iterable, List, Sequence
10+
from typing import List
1011

1112
import pillow_heif # type: ignore
1213

@@ -36,17 +37,37 @@ class _OptimizationResult:
3637
optimized_size: int
3738

3839

39-
class ImageOptimizationInsight(Insight[ImageOptimizationInsightResult]):
40-
"""Analyse image optimisation opportunities in iOS apps."""
40+
class BaseImageOptimizationInsight(Insight[ImageOptimizationInsightResult], ABC):
41+
"""Base class for image optimization insights with shared analysis logic."""
4142

4243
OPTIMIZABLE_FORMATS = {"png", "jpg", "jpeg", "heif", "heic"}
4344
MIN_SAVINGS_THRESHOLD = 4096
4445
TARGET_JPEG_QUALITY = 85
4546
TARGET_HEIC_QUALITY = 85
4647
_MAX_WORKERS = 4
4748

49+
@abstractmethod
50+
def _find_images(self, input: InsightsInput) -> List[FileInfo]:
51+
"""Find and return list of images to analyze. Should include deduplication if needed."""
52+
pass
53+
54+
def _preprocess_image(self, img: Image.Image, file_info: FileInfo) -> tuple[Image.Image, int, int]:
55+
"""Preprocess image before optimization analysis.
56+
57+
Args:
58+
img: The loaded PIL Image
59+
file_info: File metadata
60+
61+
Returns:
62+
Tuple of (processed_image, baseline_size, baseline_savings):
63+
- processed_image: The image to analyze for optimization
64+
- baseline_size: Size of the processed image before optimization
65+
- baseline_savings: Savings from preprocessing alone (original - baseline)
66+
"""
67+
return img, file_info.size, 0
68+
4869
def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | None: # noqa: D401
49-
files = list(self._iter_optimizable_files(input.file_analysis.files))
70+
files = self._find_images(input)
5071
if not files:
5172
return None
5273

@@ -58,9 +79,8 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non
5879
result = future.result()
5980
if result and result.potential_savings >= self.MIN_SAVINGS_THRESHOLD:
6081
results.append(result)
61-
except Exception as exc: # pragma: no cover
62-
file_info = future_to_file[future]
63-
logger.error("Failed to analyse %s: %s", file_info.path, exc)
82+
except Exception: # pragma: no cover
83+
logger.exception("Failed to analyze image in thread pool")
6484

6585
if not results:
6686
return None
@@ -77,49 +97,50 @@ def _analyze_image_optimization(
7797
self,
7898
file_info: FileInfo,
7999
) -> OptimizableImageFile | None:
80-
minify_savings = 0
81-
conversion_savings = 0
82-
minified_size: int | None = None
83-
heic_size: int | None = None
84-
85-
full_path = file_info.full_path
86-
file_size = file_info.size
87-
file_type = file_info.file_type
88-
display_path = file_info.path
89-
90-
if full_path is None:
91-
logger.info("Skipping %s because it has no full path", display_path)
100+
if file_info.full_path is None:
101+
logger.info("Skipping %s because it has no full path", file_info.path)
92102
return None
93103

94104
try:
95-
with Image.open(full_path) as img:
105+
with Image.open(file_info.full_path) as img:
96106
img.load() # type: ignore
97-
fmt = (img.format or file_type).lower()
107+
108+
processed_img, baseline_size, baseline_savings = self._preprocess_image(img, file_info)
109+
110+
minify_savings = 0
111+
conversion_savings = 0
112+
minified_size: int | None = None
113+
heic_size: int | None = None
114+
115+
fmt = (processed_img.format or file_info.file_type).lower()
98116

99117
if fmt in {"png", "jpg", "jpeg"}:
100-
if res := self._check_minification(img, file_size, fmt):
118+
if res := self._check_minification(processed_img, baseline_size, fmt):
101119
minify_savings, minified_size = res.savings, res.optimized_size
102-
if res := self._check_heic_conversion(img, file_size):
120+
if res := self._check_heic_conversion(processed_img, baseline_size):
103121
conversion_savings, heic_size = res.savings, res.optimized_size
104122
elif fmt in {"heif", "heic"}:
105-
if res := self._check_heic_minification(img, file_size):
123+
if res := self._check_heic_minification(processed_img, baseline_size):
106124
minify_savings, minified_size = res.savings, res.optimized_size
107-
except Exception as exc:
108-
logger.error("Failed to process %s: %s", display_path, exc)
109-
return None
110125

111-
if max(minify_savings, conversion_savings) < self.MIN_SAVINGS_THRESHOLD:
126+
total_minify = baseline_savings + minify_savings
127+
total_conversion = baseline_savings + conversion_savings
128+
129+
if max(total_minify, total_conversion) < self.MIN_SAVINGS_THRESHOLD:
130+
return None
131+
132+
return OptimizableImageFile(
133+
file_path=file_info.path,
134+
current_size=file_info.size,
135+
minify_savings=total_minify,
136+
minified_size=minified_size,
137+
conversion_savings=total_conversion,
138+
heic_size=heic_size,
139+
)
140+
except Exception:
141+
logger.exception("Failed to open or process image file")
112142
return None
113143

114-
return OptimizableImageFile(
115-
file_path=display_path,
116-
current_size=file_size,
117-
minify_savings=minify_savings,
118-
minified_size=minified_size,
119-
conversion_savings=conversion_savings,
120-
heic_size=heic_size,
121-
)
122-
123144
def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _OptimizationResult | None:
124145
try:
125146
with io.BytesIO() as buf:
@@ -134,8 +155,8 @@ def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _Op
134155
img.save(buf, format="JPEG", quality=self.TARGET_JPEG_QUALITY, **save_params)
135156
new_size = buf.tell()
136157
return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None
137-
except Exception as exc:
138-
logger.error("Minification check failed: %s", exc)
158+
except Exception:
159+
logger.exception("Image minification optimization failed")
139160
return None
140161

141162
def _check_heic_conversion(self, img: Image.Image, file_size: int) -> _OptimizationResult | None:
@@ -144,8 +165,8 @@ def _check_heic_conversion(self, img: Image.Image, file_size: int) -> _Optimizat
144165
img.save(buf, format="HEIF", quality=self.TARGET_HEIC_QUALITY)
145166
new_size = buf.tell()
146167
return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None
147-
except Exception as exc:
148-
logger.error("HEIC conversion check failed: %s", exc)
168+
except Exception:
169+
logger.exception("Image HEIC conversion optimization failed")
149170
return None
150171

151172
def _check_heic_minification(self, img: Image.Image, file_size: int) -> _OptimizationResult | None:
@@ -154,16 +175,22 @@ def _check_heic_minification(self, img: Image.Image, file_size: int) -> _Optimiz
154175
img.save(buf, format="HEIF", quality=self.TARGET_HEIC_QUALITY)
155176
new_size = buf.tell()
156177
return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None
157-
except Exception as exc:
158-
logger.error("HEIC minification check failed: %s", exc)
178+
except Exception:
179+
logger.exception("HEIC image minification failed")
159180
return None
160181

161-
def _iter_optimizable_files(self, files: Sequence[FileInfo]) -> Iterable[FileInfo]:
162-
for fi in files:
182+
183+
class ImageOptimizationInsight(BaseImageOptimizationInsight):
184+
"""Analyse image optimisation opportunities in iOS apps."""
185+
186+
def _find_images(self, input: InsightsInput) -> List[FileInfo]:
187+
images: List[FileInfo] = []
188+
for fi in input.file_analysis.files:
163189
if fi.file_type == "car":
164-
yield from (c for c in fi.children if self._is_optimizable_image_file(c))
190+
images.extend(c for c in fi.children if self._is_optimizable_image_file(c))
165191
elif self._is_optimizable_image_file(fi):
166-
yield fi
192+
images.append(fi)
193+
return images
167194

168195
def _is_optimizable_image_file(self, file_info: FileInfo) -> bool:
169196
if file_info.file_type.lower() not in self.OPTIMIZABLE_FORMATS:

src/launchpad/size/models/apple.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ class AppleAppInfo(BaseAppInfo):
7070
default_factory=list, description="List of code signature validation errors"
7171
)
7272
main_binary_uuid: str | None = Field(None, description="UUID of the main binary")
73+
primary_icon_name: str | None = Field(None, description="Primary app icon name from Info.plist")
74+
alternate_icon_names: List[str] = Field(
75+
default_factory=list, description="Alternate app icon names from Info.plist"
76+
)
7377

7478

7579
@dataclass
@@ -160,6 +164,9 @@ class AppleInsightResults(BaseModel):
160164
unnecessary_files: UnnecessaryFilesInsightResult | None = Field(None, description="Unnecessary files analysis")
161165
audio_compression: AudioCompressionInsightResult | None = Field(None, description="Audio compression analysis")
162166
video_compression: VideoCompressionInsightResult | None = Field(None, description="Video compression analysis")
167+
alternate_icons_optimization: ImageOptimizationInsightResult | None = Field(
168+
None, description="Alternate app icons optimization analysis"
169+
)
163170

164171

165172
@dataclass

src/launchpad/size/utils/file_analysis.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def _analyze_asset_catalog(xcarchive: ZippedXCArchive, relative_path: Path) -> L
279279
full_path=element.full_path,
280280
path=str(relative_path / element.name),
281281
size=element.size,
282-
file_type=(Path(element.full_path).suffix.lstrip(".") if element.full_path else "other"),
282+
file_type=(Path(element.full_path or element.name).suffix.lstrip(".") or "other"),
283283
hash=file_hash,
284284
treemap_type=TreemapType.ASSETS,
285285
is_dir=False,

0 commit comments

Comments
 (0)