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
29 changes: 23 additions & 6 deletions src/launchpad/artifacts/android/apk.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from typing import IO, Callable, Iterator

from launchpad.parsers.android.dex.dex_mapping import DexMapping
from launchpad.parsers.android.icon.binary_xml_drawable_parser import (
BinaryXmlDrawableParser,
)
from launchpad.utils.android.apksigner import Apksigner

from ...parsers.android.dex.dex_file_parser import DexFileParser
Expand Down Expand Up @@ -114,20 +117,34 @@ def get_app_icon(self) -> bytes | None:
logger.info("No application element found in manifest")
return None

icon_path = manifest.application.icon_path
if not icon_path:
icon_path_str = manifest.application.icon_path
if not icon_path_str:
logger.info("No icon path found in manifest")
return None

icon_path = self._extract_dir / icon_path
icon_path = self._extract_dir / icon_path_str

if not icon_path.exists():
logger.info(f"Icon not found in APK: {icon_path_str}")
return None

# TODO(EME-461): Support XML icon paths
# Handle XML drawables (adaptive icons, vector drawables, shapes)
if icon_path.suffix == ".xml":
logger.info(f"Icon path {icon_path} is an XML file, which is not yet supported. Skipping.")
return None
try:
binary_res_tables = self.get_resource_tables()

binary_xml_drawable_utils = BinaryXmlDrawableParser(self._extract_dir, binary_res_tables)

icon = binary_xml_drawable_utils.render_from_path(icon_path)
Copy link
Member

Choose a reason for hiding this comment

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

I recommend adding a test case for get_app_icon () like I did for the iOS app icon parsing which basically just checks whether this creates a valid PNG image from our HackerNews fixture.

Copy link
Member Author

Choose a reason for hiding this comment

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

Will do, good idea

if icon:
return icon

logger.info(f"Could not process XML drawable for icon: {icon_path_str}")
return None
except Exception:
logger.exception(f"Error processing XML drawable for icon: {icon_path_str}")
return None

# Handle regular image files (PNG, JPEG, etc.)
with open(icon_path, "rb") as f:
return f.read()
121 changes: 27 additions & 94 deletions src/launchpad/artifacts/android/manifest/axml.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"""Utilities for parsing Android binary XML format."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, List, Sequence
from typing import Any

from launchpad.parsers.android.binary.android_binary_parser import AndroidBinaryParser
from launchpad.parsers.android.binary.types import XmlAttribute, XmlNode
from launchpad.utils.logging import get_logger

from ..resources.binary import BinaryResourceTable
Expand All @@ -14,41 +12,11 @@
logger = get_logger(__name__)


@dataclass
class XmlAttribute:
"""Represents an XML attribute in binary format."""

name: str
value: str | None
typed_value: Any | None = None


@dataclass
class XmlNode:
"""Represents an XML node in binary format."""

node_name: str
attributes: Sequence[XmlAttribute]
child_nodes: Sequence[XmlNode]


class BinaryXmlParser:
"""Parser for Android binary XML format."""

def __init__(self, buffer: bytes) -> None:
"""Initialize parser with binary buffer.

Args:
buffer: Raw bytes of the binary XML file
"""
self.buffer = buffer

def parse_xml(self) -> XmlNode | None:
"""Parse the binary XML into a tree of nodes.

Returns:
Root XML node if parsing successful, None otherwise
"""
try:
parser = AndroidBinaryParser(self.buffer)
parsed_node = parser.parse_xml()
Expand All @@ -59,7 +27,7 @@ def parse_xml(self) -> XmlNode | None:

# Convert the parser's XmlNode to our model's XmlNode
def convert_node(node: Any) -> XmlNode: # type: ignore[no-untyped-def]
attributes: List[XmlAttribute] = []
attributes: list[XmlAttribute] = []
for attr in node.attributes:
value = attr.value
typed_value = attr.typed_value
Expand All @@ -69,12 +37,12 @@ def convert_node(node: Any) -> XmlNode: # type: ignore[no-untyped-def]
if typed_value.type == "reference":
# Resource references will be resolved later by AxmlUtils
value = typed_value.value
elif typed_value.type in ["int_dec", "boolean"]:
elif typed_value.type in ["int_dec", "int_hex", "boolean"]:
value = str(typed_value.value)
elif typed_value.type == "dimension":
value = f"{typed_value.value.value}{typed_value.value.unit}"
elif typed_value.type in ["rgb8", "argb8"]:
value = f"#{typed_value.value:x}"
elif typed_value.type in ["rgb8", "argb8", "rgb4", "argb4"]:
value = typed_value.value
elif typed_value.type == "string":
value = typed_value.value
elif typed_value.type == "unknown":
Expand All @@ -84,12 +52,23 @@ def convert_node(node: Any) -> XmlNode: # type: ignore[no-untyped-def]
float_view = struct.unpack("<f", struct.pack("<I", typed_value.value))[0]
value = str(float_view)

attributes.append(XmlAttribute(name=attr.name, value=value, typed_value=typed_value))
attributes.append(
XmlAttribute(
namespace_uri=attr.namespace_uri,
name=attr.name,
node_name=attr.node_name,
node_type=attr.node_type,
value=value,
typed_value=typed_value,
)
)

# Recursively convert child nodes
child_nodes = [convert_node(child) for child in node.child_nodes]

return XmlNode(
namespace_uri=node.namespace_uri,
node_type=node.node_type,
node_name=node.node_name,
attributes=attributes,
child_nodes=child_nodes,
Expand All @@ -107,20 +86,8 @@ class AxmlUtils:

@staticmethod
def binary_xml_to_android_manifest(
buffer: bytes, binary_resource_tables: List[BinaryResourceTable]
buffer: bytes, binary_resource_tables: list[BinaryResourceTable]
) -> AndroidManifest:
"""Convert binary XML buffer to AndroidManifest.

Args:
buffer: Raw bytes of the binary XML file
binary_resource_tables: List of resource tables for resolving references

Returns:
Parsed Android manifest

Raises:
ValueError: If manifest cannot be parsed or required fields are missing
"""
xml_node = BinaryXmlParser(buffer).parse_xml()
if not xml_node:
raise ValueError("Could not load binary manifest for APK")
Expand Down Expand Up @@ -202,20 +169,10 @@ def binary_xml_to_android_manifest(

@staticmethod
def get_optional_attr_value(
attributes: Sequence[XmlAttribute],
attributes: list[XmlAttribute],
name: str,
binary_res_tables: List[BinaryResourceTable],
binary_res_tables: list[BinaryResourceTable],
) -> str | None:
"""Get optional attribute value, resolving resource references if needed.

Args:
attributes: List of XML attributes
name: Name of attribute to find
binary_res_tables: List of resource tables for resolving references

Returns:
Attribute value if found and resolved, None otherwise
"""
attribute = next((attr for attr in attributes if attr.name == name), None)

if not attribute:
Expand All @@ -238,14 +195,12 @@ def get_optional_attr_value(
return str(typed_value.value)
elif typed_value.type == "reference":
return AxmlUtils.get_resource_from_binary_resource_files(typed_value.value, binary_res_tables)
elif typed_value.type == "int_dec":
elif typed_value.type in ["int_dec", "int_hex", "boolean"]:
return str(typed_value.value)
elif typed_value.type == "dimension":
return f"{typed_value.value.value}{typed_value.value.unit}"
elif typed_value.type in ["rgb8", "argb8"]:
return f"#{typed_value.value:x}"
elif typed_value.type == "boolean":
return str(typed_value.value)
elif typed_value.type in ["rgb8", "argb8", "rgb4", "argb4"]:
value = typed_value.value
elif typed_value.type == "unknown":
# Convert IEEE 754 integer representation to float
import struct
Expand All @@ -264,39 +219,17 @@ def get_optional_attr_value(

@staticmethod
def get_required_attr_value(
attributes: Sequence[XmlAttribute],
attributes: list[XmlAttribute],
name: str,
binary_res_tables: List[BinaryResourceTable],
binary_res_tables: list[BinaryResourceTable],
) -> str:
"""Get required attribute value, raising error if not found.

Args:
attributes: List of XML attributes
name: Name of attribute to find
binary_res_tables: List of resource tables for resolving references

Returns:
Attribute value if found and resolved

Raises:
ValueError: If attribute not found or cannot be resolved
"""
value = AxmlUtils.get_optional_attr_value(attributes, name, binary_res_tables)
if value is None:
raise ValueError(f"Missing required attribute: {name}")
return value

@staticmethod
def get_resource_from_binary_resource_files(value: str, binary_res_tables: List[BinaryResourceTable]) -> str | None:
"""Get resource value from binary resource tables.

Args:
value: Resource ID string (e.g. "resourceId:0x7f010001")
binary_res_tables: List of resource tables to search

Returns:
Resolved resource value if found, None otherwise
"""
def get_resource_from_binary_resource_files(value: str, binary_res_tables: list[BinaryResourceTable]) -> str | None:
# Try each table until we find a value
for table in binary_res_tables:
try:
Expand Down
Loading
Loading