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
26 changes: 26 additions & 0 deletions src/launchpad/artifacts/apple/zipped_xcarchive.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ def get_plist(self) -> dict[str, Any]:
except Exception as e:
raise RuntimeError("Failed to parse Info.plist") from e

def get_icon_info(self) -> tuple[str | None, list[str]]:
"""Extract primary and alternate icon names from Info.plist.

Returns:
Tuple of (primary_icon_name, alternate_icon_names list)
"""
plist = self.get_plist()
bundle_icons = plist.get("CFBundleIcons", {})

primary_icon_name: str | None = None
alternate_icon_names: list[str] = []

primary_icon = bundle_icons.get("CFBundlePrimaryIcon", {})
if isinstance(primary_icon, dict):
primary_icon_name = primary_icon.get("CFBundleIconName")

alternate_icons = bundle_icons.get("CFBundleAlternateIcons", {})
if isinstance(alternate_icons, dict):
for icon_key, icon_data in alternate_icons.items():
if isinstance(icon_data, dict):
icon_name = icon_data.get("CFBundleIconName")
if icon_name:
alternate_icon_names.append(icon_name)

return primary_icon_name, alternate_icon_names

def generate_ipa(self, output_path: Path):
"""Generate an IPA file

Expand Down
8 changes: 8 additions & 0 deletions src/launchpad/size/analyzers/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from launchpad.parsers.apple.swift_symbol_type_aggregator import SwiftSymbolTypeAggregator
from launchpad.size.constants import APPLE_FILESYSTEM_BLOCK_SIZE
from launchpad.size.hermes.utils import make_hermes_reports
from launchpad.size.insights.apple.alternate_icons_optimization import AlternateIconsOptimizationInsight
from launchpad.size.insights.apple.image_optimization import ImageOptimizationInsight
from launchpad.size.insights.apple.localized_strings import LocalizedStringsInsight
from launchpad.size.insights.apple.localized_strings_minify import MinifyLocalizedStringsInsight
Expand Down Expand Up @@ -227,6 +228,9 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults:
unnecessary_files=self._generate_insight_with_tracing(
UnnecessaryFilesInsight, insights_input, "unnecessary_files"
),
alternate_icons_optimization=self._generate_insight_with_tracing(
AlternateIconsOptimizationInsight, insights_input, "alternate_icons_optimization"
),
# TODO: enable audio/video compression insights once we handle ffmpeg
# audio_compression=self._generate_insight_with_tracing(
# AudioCompressionInsight, insights_input, "audio_compression"
Expand Down Expand Up @@ -293,6 +297,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
is_code_signature_valid = False
code_signature_errors = [str(e)]

primary_icon_name, alternate_icon_names = xcarchive.get_icon_info()

return AppleAppInfo(
name=plist.get("CFBundleName", "Unknown"),
app_id=plist.get("CFBundleIdentifier", "unknown.bundle.id"),
Expand All @@ -310,6 +316,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
is_code_signature_valid=is_code_signature_valid,
code_signature_errors=code_signature_errors,
main_binary_uuid=xcarchive.get_main_binary_uuid(),
primary_icon_name=primary_icon_name,
alternate_icon_names=alternate_icon_names,
)

def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]:
Expand Down
76 changes: 76 additions & 0 deletions src/launchpad/size/insights/apple/alternate_icons_optimization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import io

from pathlib import Path
from typing import List

from PIL import Image

from launchpad.size.insights.apple.image_optimization import BaseImageOptimizationInsight
from launchpad.size.insights.insight import InsightsInput
from launchpad.size.models.apple import AppleAppInfo
from launchpad.size.models.common import FileInfo
from launchpad.utils.logging import get_logger

logger = get_logger(__name__)


class AlternateIconsOptimizationInsight(BaseImageOptimizationInsight):
"""Analyze alternate app icon optimization opportunities in iOS apps.

Alternate app icons can be optimized without affecting the App Store listing since
only the primary icon is displayed there. This insight identifies alternate icons
that could be minified or converted to more efficient formats like HEIC.

Icons are resized to device display size (180px for iPhone 3x) and back to store
size (1024px) before optimization, since they only need quality for homescreen display.
"""

IPHONE_3X_ICON_SIZE = 180 # Largest icon size displayed on device
APP_STORE_ICON_SIZE = 1024 # Standard App Store icon size

def _find_images(self, input: InsightsInput) -> List[FileInfo]:
if not isinstance(input.app_info, AppleAppInfo):
return []

if not input.app_info.alternate_icon_names:
return []

alternate_icon_names = set(input.app_info.alternate_icon_names)
car_files = [f for f in input.file_analysis.files if f.file_type == "car"]

images: List[FileInfo] = []
for car_file in car_files:
if not car_file.children or (len(car_file.children) == 1 and car_file.children[0].path.endswith("/Other")):
logger.warning(
"Asset catalog %s has no parsed children. ParsedAssets directory may be missing.", car_file.path
)
continue

for child in car_file.children:
if self._is_alternate_icon_file(child, alternate_icon_names):
images.append(child)

return list({img.path: img for img in images}.values())

def _preprocess_image(self, img: Image.Image, file_info: FileInfo) -> tuple[Image.Image, int, int]:
resized = self._resize_icon_for_analysis(img)

fmt = img.format or "PNG"
with io.BytesIO() as buf:
resized.save(buf, format=fmt)
resized_size = buf.tell()

baseline_savings = max(0, file_info.size - resized_size)
return resized, resized_size, baseline_savings

def _resize_icon_for_analysis(self, img: Image.Image) -> Image.Image:
return img.resize((self.IPHONE_3X_ICON_SIZE, self.IPHONE_3X_ICON_SIZE), Image.Resampling.LANCZOS).resize(
(self.APP_STORE_ICON_SIZE, self.APP_STORE_ICON_SIZE), Image.Resampling.LANCZOS
)

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
)
121 changes: 74 additions & 47 deletions src/launchpad/size/insights/apple/image_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import io
import logging

from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Sequence
from typing import List

import pillow_heif # type: ignore

Expand Down Expand Up @@ -36,17 +37,37 @@ class _OptimizationResult:
optimized_size: int


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

OPTIMIZABLE_FORMATS = {"png", "jpg", "jpeg", "heif", "heic"}
MIN_SAVINGS_THRESHOLD = 4096
TARGET_JPEG_QUALITY = 85
TARGET_HEIC_QUALITY = 85
_MAX_WORKERS = 4

@abstractmethod
def _find_images(self, input: InsightsInput) -> List[FileInfo]:
"""Find and return list of images to analyze. Should include deduplication if needed."""
pass

def _preprocess_image(self, img: Image.Image, file_info: FileInfo) -> tuple[Image.Image, int, int]:
"""Preprocess image before optimization analysis.

Args:
img: The loaded PIL Image
file_info: File metadata

Returns:
Tuple of (processed_image, baseline_size, baseline_savings):
- processed_image: The image to analyze for optimization
- baseline_size: Size of the processed image before optimization
- baseline_savings: Savings from preprocessing alone (original - baseline)
"""
return img, file_info.size, 0

def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | None: # noqa: D401
files = list(self._iter_optimizable_files(input.file_analysis.files))
files = self._find_images(input)
if not files:
return None

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

if not results:
return None
Expand All @@ -77,49 +97,50 @@ def _analyze_image_optimization(
self,
file_info: FileInfo,
) -> OptimizableImageFile | None:
minify_savings = 0
conversion_savings = 0
minified_size: int | None = None
heic_size: int | None = None

full_path = file_info.full_path
file_size = file_info.size
file_type = file_info.file_type
display_path = file_info.path

if full_path is None:
logger.info("Skipping %s because it has no full path", display_path)
if file_info.full_path is None:
logger.info("Skipping %s because it has no full path", file_info.path)
return None

try:
with Image.open(full_path) as img:
with Image.open(file_info.full_path) as img:
img.load() # type: ignore
fmt = (img.format or file_type).lower()

processed_img, baseline_size, baseline_savings = self._preprocess_image(img, file_info)

minify_savings = 0
conversion_savings = 0
minified_size: int | None = None
heic_size: int | None = None

fmt = (processed_img.format or file_info.file_type).lower()

if fmt in {"png", "jpg", "jpeg"}:
if res := self._check_minification(img, file_size, fmt):
if res := self._check_minification(processed_img, baseline_size, fmt):
minify_savings, minified_size = res.savings, res.optimized_size
if res := self._check_heic_conversion(img, file_size):
if res := self._check_heic_conversion(processed_img, baseline_size):
conversion_savings, heic_size = res.savings, res.optimized_size
elif fmt in {"heif", "heic"}:
if res := self._check_heic_minification(img, file_size):
if res := self._check_heic_minification(processed_img, baseline_size):
minify_savings, minified_size = res.savings, res.optimized_size
except Exception as exc:
logger.error("Failed to process %s: %s", display_path, exc)
return None

if max(minify_savings, conversion_savings) < self.MIN_SAVINGS_THRESHOLD:
total_minify = baseline_savings + minify_savings
total_conversion = baseline_savings + conversion_savings

if max(total_minify, total_conversion) < self.MIN_SAVINGS_THRESHOLD:
return None

return OptimizableImageFile(
file_path=file_info.path,
current_size=file_info.size,
minify_savings=total_minify,
minified_size=minified_size,
conversion_savings=total_conversion,
heic_size=heic_size,
)
except Exception:
logger.exception("Failed to open or process image file")
return None

return OptimizableImageFile(
file_path=display_path,
current_size=file_size,
minify_savings=minify_savings,
minified_size=minified_size,
conversion_savings=conversion_savings,
heic_size=heic_size,
)

def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _OptimizationResult | None:
try:
with io.BytesIO() as buf:
Expand All @@ -134,8 +155,8 @@ def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _Op
img.save(buf, format="JPEG", quality=self.TARGET_JPEG_QUALITY, **save_params)
new_size = buf.tell()
return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None
except Exception as exc:
logger.error("Minification check failed: %s", exc)
except Exception:
logger.exception("Image minification optimization failed")
return None

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

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

def _iter_optimizable_files(self, files: Sequence[FileInfo]) -> Iterable[FileInfo]:
for fi in files:

class ImageOptimizationInsight(BaseImageOptimizationInsight):
"""Analyse image optimisation opportunities in iOS apps."""

def _find_images(self, input: InsightsInput) -> List[FileInfo]:
images: List[FileInfo] = []
for fi in input.file_analysis.files:
if fi.file_type == "car":
yield from (c for c in fi.children if self._is_optimizable_image_file(c))
images.extend(c for c in fi.children if self._is_optimizable_image_file(c))
elif self._is_optimizable_image_file(fi):
yield fi
images.append(fi)
return images

def _is_optimizable_image_file(self, file_info: FileInfo) -> bool:
if file_info.file_type.lower() not in self.OPTIMIZABLE_FORMATS:
Expand Down
7 changes: 7 additions & 0 deletions src/launchpad/size/models/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ class AppleAppInfo(BaseAppInfo):
default_factory=list, description="List of code signature validation errors"
)
main_binary_uuid: str | None = Field(None, description="UUID of the main binary")
primary_icon_name: str | None = Field(None, description="Primary app icon name from Info.plist")
alternate_icon_names: List[str] = Field(
default_factory=list, description="Alternate app icon names from Info.plist"
)


@dataclass
Expand Down Expand Up @@ -160,6 +164,9 @@ class AppleInsightResults(BaseModel):
unnecessary_files: UnnecessaryFilesInsightResult | None = Field(None, description="Unnecessary files analysis")
audio_compression: AudioCompressionInsightResult | None = Field(None, description="Audio compression analysis")
video_compression: VideoCompressionInsightResult | None = Field(None, description="Video compression analysis")
alternate_icons_optimization: ImageOptimizationInsightResult | None = Field(
None, description="Alternate app icons optimization analysis"
)


@dataclass
Expand Down
2 changes: 1 addition & 1 deletion src/launchpad/size/utils/file_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def _analyze_asset_catalog(xcarchive: ZippedXCArchive, relative_path: Path) -> L
full_path=element.full_path,
path=str(relative_path / element.name),
size=element.size,
file_type=(Path(element.full_path).suffix.lstrip(".") if element.full_path else "other"),
file_type=(Path(element.full_path or element.name).suffix.lstrip(".") or "other"),
hash=file_hash,
treemap_type=TreemapType.ASSETS,
is_dir=False,
Expand Down
Loading
Loading