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
7 changes: 5 additions & 2 deletions apkparser/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from axml.axml import AXMLPrinter

from .ressources import load_api_specific_resource_module
from .ressources import (
load_api_specific_resource_module,
_resolve_api_level,
)
from apkparser.helper.logging import LOGGER

# Dictionary of the different protection levels mapped to their corresponding attribute names as described in
Expand Down Expand Up @@ -146,7 +149,7 @@ def get_uses_implied_permission_list(self) -> list[str]:
def _update_permission_protection_level(
self, protection_level, sdk_version
):
if not sdk_version or int(sdk_version) <= 15:
if not sdk_version or _resolve_api_level(sdk_version) <= 15:
return protection_level.replace('Or', '|').lower()
return protection_level

Expand Down
97 changes: 86 additions & 11 deletions apkparser/permissions/ressources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,90 @@ class APILevelNotFoundError(Exception):
pass


# Mapping of Android SDK codenames to their official final API levels.
# Source: https://developer.android.com/tools/releases/platforms
# In Android development, preview builds use codenames (e.g. 'Q', 'Tiramisu')
# for targetSdkVersion and minSdkVersion before numeric API levels are finalized.
CODENAME_API_MAP: dict[str, int] = {
'BASE': 1,
'BASE_1_1': 2,
'CUPCAKE': 3,
'DONUT': 4,
'ECLAIR': 5,
'ECLAIR_0_1': 6,
'ECLAIR_MR1': 7,
'FROYO': 8,
'GINGERBREAD': 9,
'GINGERBREAD_MR1': 10,
'HONEYCOMB': 11,
'HONEYCOMB_MR1': 12,
'HONEYCOMB_MR2': 13,
'ICE_CREAM_SANDWICH': 14,
'ICE_CREAM_SANDWICH_MR1': 15,
'JELLY_BEAN': 16,
'JELLY_BEAN_MR1': 17,
'JELLY_BEAN_MR2': 18,
'KITKAT': 19,
'KITKAT_WATCH': 20,
'LOLLIPOP': 21,
'LOLLIPOP_MR1': 22,
'M': 23,
'N': 24,
'N_MR1': 25,
'O': 26,
'O_MR1': 27,
'P': 28,
'Q': 29,
'R': 30,
'S': 31,
'S_V2': 32,
'TIRAMISU': 33,
'T': 33,
'UPSIDE_DOWN_CAKE': 34,
'U': 34,
'VANILLA_ICE_CREAM': 35,
'V': 35,
'BAKLAVA': 36,
'B': 36,
}


def _resolve_api_level(
apilevel: Union[str, int, None], default_api: int = 16
) -> int:
"""
Resolve an API level representation to a valid integer API level.
Supports integers, numeric strings, and known Android SDK codenames.
Falls back to default_api with a warning if the value is unknown or malformed.
"""
if apilevel is None:
return default_api

if isinstance(apilevel, int):
return apilevel

try:
return int(apilevel)
except (ValueError, TypeError):
clean_codename = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(apilevel).strip())
clean_codename = clean_codename.upper().replace(' ', '_').replace('-', '_')
if clean_codename in CODENAME_API_MAP:
return CODENAME_API_MAP[clean_codename]

LOGGER.warning(
f"Unknown or malformed API level '{apilevel}', falling back to default API level {default_api}"
)
return default_api


def load_api_specific_resource_module(
resource_name: str, api: Union[str, int, None] = None, default_api: int = 16
) -> dict:
"""
Load the module from the JSON files and return a dict, which might be empty
if the resource could not be loaded.

If no api version is given, the default one from the CONF dict is used.
If no api version is given, default_api is used.

:param resource_name: Name of the resource to load
:param api: API version
Expand All @@ -40,17 +116,15 @@ def load_api_specific_resource_module(
)
)

if not api:
api = default_api

ret = loader[resource_name](api)
resolved_api = _resolve_api_level(api, default_api=default_api)
ret = loader[resource_name](resolved_api)

if ret == {}:
# No API mapping found, return default
LOGGER.warning(
"API mapping for API level {} was not found! "
"Returning default, which is API level {}".format(
api, CONF['DEFAULT_API']
resolved_api, default_api
)
)
ret = loader[resource_name](default_api)
Expand All @@ -73,16 +147,15 @@ def load_permissions(
the lower level is returned. For example, if 5,6,7,10 is available and 8 is
requested, 7 is returned instead.

:param apilevel: integer value of the API level
:param apilevel: integer value or codename string of the API level
:param permtype: either load permissions (`'permissions'`) or
permission groups (`'groups'`)
:return: a dictionary of {Permission Name: {Permission info}
"""
if permtype not in ['permissions', 'groups']:
raise ValueError("The type of permission list is not known.")

# Usually apilevel is supplied as string...
apilevel = int(apilevel)
apilevel = _resolve_api_level(apilevel)

root = os.path.dirname(os.path.realpath(__file__))
permissions_file = os.path.join(
Expand Down Expand Up @@ -137,11 +210,13 @@ def load_permission_mappings(
) -> dict[str, list[str]]:
"""
Load the API/Permission mapping for the requested API level.
If the requetsed level was not found, None is returned.
If the requested level was not found, None is returned.

:param apilevel: integer value of the API level, i.e. 24 for Android 7.0
:param apilevel: integer value or codename string of the API level, i.e. 24 for Android 7.0
:return: a dictionary of {MethodSignature: [List of Permissions]}
"""
apilevel = _resolve_api_level(apilevel)

root = os.path.dirname(os.path.realpath(__file__))
permissions_file = os.path.join(
root, "api_permission_mappings", "permissions_{}.json".format(apilevel)
Expand Down
84 changes: 84 additions & 0 deletions tests/test_sdk_codename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
import unittest

from apkparser.permissions.ressources import (
load_permissions,
load_permission_mappings,
load_api_specific_resource_module,
_resolve_api_level,
CODENAME_API_MAP,
)


class SdkCodenameResolutionTest(unittest.TestCase):
def test_integer_api_level(self):
"""Existing integer API levels must behave exactly as before."""
self.assertEqual(_resolve_api_level(29), 29)
self.assertEqual(_resolve_api_level(33), 33)
self.assertEqual(_resolve_api_level(35), 35)

perms_29 = load_permissions(29)
self.assertIsInstance(perms_29, dict)
self.assertIn('android.permission.INTERNET', perms_29)

def test_numeric_string_api_level(self):
"""Numeric strings representing API levels must continue working."""
self.assertEqual(_resolve_api_level("29"), 29)
self.assertEqual(_resolve_api_level("33"), 33)
self.assertEqual(_resolve_api_level("35"), 35)

self.assertEqual(load_permissions("29"), load_permissions(29))
self.assertEqual(
load_api_specific_resource_module("aosp_permissions", "29"),
load_api_specific_resource_module("aosp_permissions", 29),
)

def test_known_sdk_codenames(self):
"""Known Android SDK codenames must resolve to their correct integer API levels."""
# Single-letter preview codenames
self.assertEqual(_resolve_api_level("N"), 24)
self.assertEqual(_resolve_api_level("P"), 28)
self.assertEqual(_resolve_api_level("Q"), 29)
self.assertEqual(_resolve_api_level("q"), 29)
self.assertEqual(_resolve_api_level("T"), 33)
self.assertEqual(_resolve_api_level("U"), 34)
self.assertEqual(_resolve_api_level("V"), 35)
self.assertEqual(_resolve_api_level("B"), 36)

# Full codenames with case and format variations
self.assertEqual(_resolve_api_level("Tiramisu"), 33)
self.assertEqual(_resolve_api_level("TIRAMISU"), 33)
self.assertEqual(_resolve_api_level("UpsideDownCake"), 34)
self.assertEqual(_resolve_api_level("VanillaIceCream"), 35)
self.assertEqual(_resolve_api_level("Baklava"), 36)
self.assertEqual(_resolve_api_level("BAKLAVA"), 36)

# Issue #3 core assertion: 'Q' loads API 29 permissions without ValueError
perms_q = load_permissions("Q")
self.assertIsInstance(perms_q, dict)
self.assertEqual(perms_q, load_permissions(29))
self.assertIn('android.permission.INTERNET', perms_q)

# load_api_specific_resource_module with codename
perms_module_q = load_api_specific_resource_module("aosp_permissions", "Q")
self.assertEqual(perms_module_q, load_api_specific_resource_module("aosp_permissions", 29))

def test_unknown_or_malformed_sdk_string(self):
"""Unknown or malformed SDK strings must fall back safely and not raise ValueError."""
# Must fall back to default_api (default: 16)
resolved = _resolve_api_level("UNKNOWN_FUTURE_CODENAME", default_api=16)
self.assertEqual(resolved, 16)

# load_permissions should safely load fallback rather than raise ValueError
perms_unknown = load_permissions("UNKNOWN_FUTURE_CODENAME")
self.assertIsInstance(perms_unknown, dict)
self.assertEqual(perms_unknown, load_permissions(16))

def test_none_api_level(self):
"""None value falls back to default_api."""
self.assertEqual(_resolve_api_level(None, default_api=16), 16)
self.assertEqual(_resolve_api_level(None, default_api=21), 21)


if __name__ == '__main__':
unittest.main()